mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* feat: surface interrupt-steer escalation on waiting messages The interrupt & steer feature shipped reachable only through the composer chord, the send-button hovercard, and the composer button; a message already waiting (queued for after the run, or steered and parked at the next tool boundary) had no path to it. Both waiting surfaces now carry one: - Queued rows get an icon-only ZapOff escalation button beside the existing Steer primary. It routes through sendQueuedNow, which now takes a preempt option on its live-run path. The tooltip teaches the composer chord, derived through resolveComposerKeyDown so a rebound or yielded chord is never advertised. - In-flight steer bubbles get an "Interrupt now" overflow entry with the same race rules as Edit: reclaim first, and only a `reclaimed` outcome resubmits (via retrySteer with preempt, swapping the chip for an interrupting one). `applied` and run-ended-mid-reclaim outcomes stop at the existing informational toasts, so the words can never land twice. Not offered on a steer already preempting. - Every during-run overflow menu gains an "Always interrupt instead" toggle for steerInterruptsByDefault, next to the existing steer/queue default toggle. MenuEntry supports disabled for the new entries. Only one interrupt can be unresolved at a time: while one preempt is pending (or the run is paused on approval, where the server 409s), every escalation control disables instead of racing the same seal. Ten new tests across both surfaces; 381 green in the affected suites. * fix: lock escalation across its reclaim window, keep the paused control visible, label as steer Codex round 1, all three findings. P2, escalation race. The single-interrupt invariant had a window between clicking "Interrupt now" and the reclaim resolving, where no preempt chip existed for the chip-derived gate to see: two bubbles escalated back-to-back could both resubmit. A shared escalating flag (Jotai, per-conversation) now covers the window and disables every escalation control on both surfaces, and a fresh recheck before resubmitting catches an interrupt armed elsewhere meanwhile (composer chord, queued row); those words re-home to the queue with an informational toast instead of breaking the invariant. P2, unreachable paused state. canSteer is defined as hasRealConvoId && !pausedOnApproval, so gating the button on canSteer removed it exactly when it was meant to render disabled; the test only passed on an impossible stub combination. The render gate is now duringRunActive && (canSteer || pausedOnApproval), and the test uses the real invariant. P2, label semantics. "Interrupt & send now" borrowed the name of the hard-abort action; this one preserves the partial answer and steers. Renamed to "Interrupt & steer now" (com_ui_interrupt_steer_now). Both behavior fixes counterfactually verified; 384 tests green across the affected suites. * fix: disable bubble escalation while the run cannot accept a steer Codex round 2, one P2. Answer mode (ask_user_question) sets duringRunActive false while pausedOnApproval stays false, since that flag only detects approval-bearing tool calls. The bubble's escalation entry stayed enabled there, so clicking it cancelled a healthy waiting steer and the preempt resubmission bounced off RUN_PAUSED, degrading the words to the queue. The entry now also disables on !duringRunActive, matching the queued-row control's gate. Counterfactually verified: reverting the gate fails the new answer-mode test. * fix: recheck live run state after the reclaim, not just at the click Codex round 3, one P2, and it is the round-1 recheck principle applied one level deeper: the entry-time disable cannot see a run that pauses (tool approval, answer mode) while the reclaim round-trip is in flight, and the .then closure held the render's stale steering controls, so the resubmit would fire into a RUN_PAUSED rejection after the reclaim had already surrendered the steer's boundary slot. The escalation continuation now reads the LIVE controls through a latest-ref: if the run can no longer accept a steer, the words re-home to the queue with an informational toast instead of resubmitting, and the resubmit itself also goes through the live controls. Counterfactually verified: reading the stale closure instead of the ref fails the new mid-reclaim pause test. * refactor: make escalation one atomic server-side arm, in place Codex round 4: four P2s, every one an interleaving of the same window — escalation as reclaim-then-repost is a compound, non-atomic operation whose continuation must revalidate the world (FIFO position lost, ref assigned too late, no run fence, competing bubble actions). Rounds 1-3 patched that window with a lock and rechecks; round 4 shows the window itself is the defect, so this removes it instead of guarding it again. Escalation is now POST /chat/steer/arm: the server flips preempt on the EXISTING queued item in one atomic store op (new IJobStore.armSteer; a decode-patch-encode LSET Lua on Redis, an in-place mutation in memory), fenced to the validated generation and refused once the queue closes. The handler mirrors the steer POST's preempt contract exactly: durable flag gated on the owner's recorded capability, volatile requestPreempt fire-and-forget because the durable flag is the truth resume/handover re-arm from. By construction this resolves all four findings: FIFO survives (the item never moves; the whole queue still drains in instruction order at the seal), there is no continuation to hold stale controls, the store op is fenced to the original run, and a competing Edit/Queue/Cancel either beats the arm (armed:false, chip untouched) or operates on the armed item, whose cancel already disarms. The client escalation entry becomes one mutation: armed:true relabels the chip in place (same steerId, same position), PREEMPT_UNSUPPORTED and lost races toast honestly, and the round 1-3 machinery — the escalating lock atom, the latest-ref, the post-reclaim rechecks and their two toast strings — is deleted rather than extended. Verified: 7 new handler tests on the real in-memory manager (including FIFO preservation and the stale-generation fence), 2 Redis integration tests against real Redis (in-place arm keeps order and every field; missing/stale/closed all refuse), client suites 396 green. * fix: decide capability inside the atomic arm, neutralize the lost-race toast Codex round 5, both findings, both edges of the new arm design rather than its mechanism. P2, capability TOCTOU. A HITL resume on a rolling deploy rewrites preemptCapable for the SAME generation, so the handler's read could go stale between validation and the flag flip, arming a steer the live owner cannot seal. armSteer now returns armed | missing | incapable, with the owner's live capability part of the same atomic predicate as the generation fence (HGET preemptCapable inside the Lua; the flat job field, not a metadata blob — the in-memory store reads the same field). The handler's pre-check is deleted rather than kept alongside; the store predicate is the single source. New handler test rewrites the capability after queueing and expects PREEMPT_UNSUPPORTED with the item left unflagged; the Redis guards test now asserts the incapable refusal against real Redis. P2, ambiguous toast. armed:false covers injected, cancelled, re-homed, and run-over alike, so telling the user the message "already reached the agent" claimed one specific outcome. The lost-race branch now uses a neutral message (com_ui_steer_arm_lost_race) and defers to the events for what actually happened. * fix: flip the escalation lock synchronously before the arm request Codex round 6, one P2. Round 4 deleted the escalating flag along with the reclaim continuation it guarded, but that left the one-interrupt gate blind during the arm request's own round trip: the chip-derived check cannot see an arm until its response relabels the chip, so on a slow connection two bubbles could both arm before either response landed. Double-arm is harmless server-side now (the run seals once and drains the whole queue in order), but every escalation control advertises "one interrupt at a time" by disabling, and the controls must tell the truth. The per-conversation escalating flag returns as a pure UX gate: set synchronously at click, before the mutation, cleared on settlement, and folded into interruptPending on both surfaces. Unlike its round 1-3 ancestor there is no continuation behind it to guard and no recheck to pair with it. Counterfactually verified: without the synchronous set, the two-bubble race test arms twice. 207 tests green across the Chat Input suites. * test(e2e): cover escalation of waiting messages through the real seal Three mock-harness tests on E2E_SLOW_REPLY, a 160-chunk stream with no tool boundary, so an in-thread steer part can ONLY come from a genuine mid-stream seal — which makes each test a behavioral proof rather than a UI check: - Queued row escalation: the ZapOff button turns a waiting queued message into a preempt-armed steer (202 echoes preempt: true) that seals and injects, where the sibling steering.spec test proves the unescalated path waits for run end instead. - Bubble in-place arm: an ordinary steer (202 with no preempt echo) waits as a bubble, POST /chat/steer/arm answers armed: true, the bubble relabels in place (same single bubble, same text, escalation no longer offered on reopen), and the armed steer seals mid-stream. - Always-interrupt toggle: flipped from a waiting row's overflow menu, plain Enter now produces a preempt: true steer that seals in the SAME run, and the menu offers the way back. An afterEach clears the localStorage preference so a mid-test failure cannot leak preempt-by-default into the rest of the serial suite. All three verified locally through the full harness (real backend, mock LLM, seeded DB): 3 passed in 27s. * feat: dedicated escalation arrow + shortcut, menu split into actions and preferences The escalation was still half-hidden: the bubble only offered it inside the overflow menu, and the tooltip taught the composer chord, which does a different thing (interrupts with typed text, not this chip). Three changes make it a first-class command: - A shared EscalateNowButton (circular arrow, ghost-bordered like the composer's interrupt control) is always visible on BOTH surfaces: beside each queued row's Steer primary and on every waiting steer bubble next to its menu. It disappears once a steer is interrupting. - A dedicated registry shortcut, escalateSteer (Cmd/Ctrl+Shift+.), editing-allowed and rebindable like every other action. Deliberately NOT an Enter chord: the composer owns every Enter chord, and the yield design rests on no default binding using Enter besides submit. Its handler clicks the newest enabled arrow control (bubbles beat queued rows), so the shortcut can never diverge from the button, and the arrow's tooltip teaches THIS command via the registry display. - The overflow menus separate one-off actions from sticky behavior changes: Edit, Cancel, Queue, then a smaller "Preferences" section holding the queueing and always-interrupt toggles, each with the standard InfoHoverCard reusing the Settings panel's descriptions. "Interrupt & steer now" leaves the menu entirely. 386 client tests green, including a menu-structure test locking the order and the absence of the escalation entry; bubble escalation tests drive the visible arrow. The e2e spec's bubble test now clicks the arrow, and a fourth test drives the dedicated shortcut end to end through a real mid-stream seal. * style: bind the escalation arrow to its message (variant A anatomy) Two same-weight circles in a row read as one control group, leaving the arrow's ownership ambiguous, and a floating arrow stops meaning anything once several messages stack. The shared control now carries variant A's anatomy: a thin divider binds a small SOLID arrow (filled, inverted) to the message region on its left, and the menu ellipsis stays a bare glyph, so the two affordances can no longer blur together — and the divider+arrow pairing repeats cleanly per chip at N messages. * chore: drop the unused within import CI lint caught * fix: advertise the escalation shortcut only while the control is live Codex on the e2e head, one P2: the tooltip appended the chord hint even while the button was disabled, advertising a shortcut that does nothing during an approval pause. The flagged control (InterruptNowButton) was since replaced by the shared EscalateNowButton, which inherited the pattern; the successor now omits the chord whenever the control is disabled, matching the rule the during-run hovercard already follows. * fix: harden steer escalation lifecycle and recovery * test(e2e): disambiguate accessible steer preferences * test: align abort persistence coverage with prerequisites * chore(i18n): remove obsolete steer race message * chore: normalize imports across steering changes * test: exercise stream integration on Redis Cluster * test: scope HITL checkpoints to generation * test: fix cluster cleanup and locale policy * fix: keep escalation visible during ask pauses * fix: fence recovery downgrade and stale predecessors * fix: require generation owner abort acknowledgement * fix: validate delayed preempt arms * test: align final escalation fixtures * fix: preserve in-memory predecessor abort handoff * fix: restore controls for recovered queued messages * test: cover recovered queue controls * fix: close final steering review gaps
1800 lines
64 KiB
JavaScript
1800 lines
64 KiB
JavaScript
const { Constants } = 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,
|
|
);
|
|
});
|
|
|
|
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,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|