mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-01 20:01:35 +00:00
* 🌿 fix: Preserve Viewed Branch on Sibling-Tree Churn Regenerating a message could snap the view to an unrelated newest branch. MultiMessage reset siblingIdx to 0 (newest) on any messagesTree.length change, but getRegenerateSubmissionMessages slices the flat message array during a regenerate — the streaming handlers render a tree missing unrelated sibling branches, then finalHandler restores the full set. That 2→1→2 child-count swing snapped unrelated forks to their newest sibling, so regenerating the latest response on an older branch jumped to a previously regenerated branch. Replace the indiscriminate reset with per-fork branch memory: a 'seen' set distinguishes a genuinely new sibling (submission/regeneration/edit here — focus it) from one transiently dropped and restored (preserve the user's branch). Decision extracted as the pure, unit-tested resolveSiblingSelection. - client/src/utils/messages.ts: resolveSiblingSelection + tests - MultiMessage: seen/selectedId refs, structural id-signature effect - e2e: regenerate-latest-on-older-branch keeps the viewed branch (fails on the old reset, passes now) * 🧪 test: Long-Thread Branch Preservation E2E Add the user-reported scenario: in a multi-turn thread, regenerate an earlier response (forking a root branch), switch back to the original, then regenerate a later response on it — the original branch must stay intact. Uses labeled prompts so each turn's unique reply is a reliable settle signal. Verified it fails on the original MultiMessage and passes with the fix. * 🎨 style: Fix import order in MultiMessage (react before recoil) * 🌿 fix: Keep Unrelated Branches in Regenerate Optimistic Render Regenerating a message used a flat `messages.slice(0, targetIndex)` for the optimistic render, which also drops unrelated sibling branches that merely sit later in the flat array. Mid-regenerate the thread briefly collapsed to a short branch (visible flash) and the scroll jumped to the shrunken content and didn't recover — the same flat-array root cause as the branch-reset bug. Remove only the regenerated response and its descendants, keeping unrelated branches. The thread (and scroll) stay put through the regenerate. This array is render-only — the server regenerates from parentMessageId and createPayload doesn't include it — so summing by subtree never affects the request. Verified via a small-viewport scroll trace: old collapses 903->295px / 8->2 renders mid-stream; fixed stays 903px / 8 renders, scroll held at bottom. Unit test covers the keep-unrelated-branches behavior (fails on the old slice). * 🌿 fix: Let an Explicit Branch Selection Survive Streaming ID Churn resolveSiblingSelection focused any unseen sibling id before checking the committed selection. When an in-flight response's id is replaced mid-stream (placeholder → server/run id, e.g. useStepHandler re-keys to runId) after the user switched to a different sibling, that swap looked like a brand-new sibling and stole focus back to the streaming branch. Reorder: the committed selection wins while still present; only focus a fresh sibling when the selection is gone (regenerated away, or its own placeholder id was just replaced — that's how a regen/edit still takes focus, since the slice removes the old response). Added unit tests for both churn directions. * 🌿 fix: Only Focus a New Sibling When the Fork Actually Grew The previous churn fix (selection-wins-first) was too aggressive: a genuinely new sibling ADDED while the prior selection is still present — e.g. a follow-up re-parented as a sibling after a generation-start failure — was no longer focused, so its reply never rendered (broke message-tree generation-start recovery e2e). Gate new-sibling focus on actual growth: resolveSiblingSelection now takes prevCount and only focuses a never-seen id when ids.length > prevCount. A same-count placeholder→server id swap (churn) or a restored already-seen sibling is not growth, so the committed selection still wins there. Covers follow-up/new-branch focus, churn steal-prevention, and self-churn follow. message-tree + chat e2e: 17 passed (incl. the recovered generation-start test). * 🌿 refactor: Drop MultiMessage Branch-Memory in Favor of the Slice Fix The regenerate-slice fix (keep unrelated branches in the optimistic render) is the true root cause: with no spurious tree collapse, the original setSiblingIdx(0)-on-length-change never misfires, so the branch-reset is fixed without per-fork memory. The earlier MultiMessage rewrite (seen/selectedId/ prevCount + resolveSiblingSelection) was a symptom patch added before the root cause was found, and its per-instance memory generated two edge-case findings (placeholder→server id churn; divergence from external siblingIdx writes like resume restore). Revert MultiMessage to the simple upstream version and remove resolveSiblingSelection (+ its tests). The slice fix + the existing branch e2e (chat.spec: switch-back, regenerate-latest, long-thread) cover the behavior; all 17 chat + message-tree branch specs pass with this version. * 🌿 fix: Focus the Regenerated Response When Its Fork Count Is Unchanged When a parent already has multiple sibling responses and the user switches to a non-latest one and regenerates it, the optimistic slice drops the target but keeps the other siblings, so the child count is unchanged. MultiMessage only resets the (reversed) sibling index on a length change, so the stale index kept pointing at the kept sibling and the regenerating response stayed hidden until the server restored the dropped sibling at finalize (count bump → reset). Explicitly focus the newest sibling (reversed index 0 = the appended response) of the regenerated fork in createdHandler. Position-based, fires only on the regenerate action, so it doesn't reintroduce the placeholder→server id churn or external-write fragility that a per-render selection memory had. E2E: new during-stream test (slow+counted reply marker) asserting the regenerating response is visible before finalize; negatively verified (fails without the focus call, passes with it). * 🌿 fix: Eliminate Pre-Created Flash by Focusing at the Optimistic Render The createdHandler focus removed the until-finalize bug, but a brief flash remained between clicking regenerate and the `created` event: useChatFunctions renders the optimistic placeholder first, and that render has the same unchanged-count problem, so the kept sibling showed until createdHandler fired. Extract the focus into a shared useFocusRegeneratedResponse hook and apply it at the optimistic render too (useChatFunctions) and on `created` (useEventHandlers). The placeholder is now focused from the first frame. E2E: gated pre-created test — holds the SSE stream GET (the chat POST returns a stream id; the stream is a separate GET) so `created` cannot arrive, leaving only the optimistic render, then asserts the kept sibling is already gone. This isolates the optimistic focus (createdHandler cannot mask it); negatively verified (fails without the optimistic focus call). * 🧪 test: Extend Store Mock for the Regenerate Focus Hook useChatFunctions.regenerate.spec.tsx mocks ~/store and recoil partially; the new useFocusRegeneratedResponse calls store.messagesSiblingIdxFamily via a recoil `set`, neither of which the mock provided (TypeError on regenerate). Add messagesSiblingIdxFamily to the store mock and `set` to the useRecoilCallback mock. Test-only; production code unchanged.
538 lines
16 KiB
JavaScript
538 lines
16 KiB
JavaScript
/**
|
|
* In-process fake LLM for credential-free e2e tests. Loaded by `@librechat/api`'s
|
|
* `createRun` via the `LIBRECHAT_TEST_RUN_HOOK` env var (set by the mock
|
|
* Playwright config and the `--profile=mock` recorder), it swaps the run's model
|
|
* for the agents package's own `FakeChatModel` through
|
|
* `run.Graph.overrideTestModel(...)`.
|
|
*
|
|
* This exercises the real `Run.create` -> graph -> tool-node pipeline end to end
|
|
* without a live provider or a standalone HTTP mock server: responses are decided
|
|
* from the conversation and the agents' advertised tools.
|
|
*/
|
|
const { FakeChatModel } = require('@librechat/agents');
|
|
const { ChatGenerationChunk } = require('@langchain/core/outputs');
|
|
const { AIMessageChunk } = require('@langchain/core/messages');
|
|
|
|
const MOCK_REPLY = process.env.MOCK_LLM_REPLY || 'E2E mock reply: pong';
|
|
const CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_CHUNK_DELAY_MS) || 10;
|
|
|
|
const CREATE_SKILL_MARKER = 'E2E_CREATE_SKILL:';
|
|
const EDIT_SKILL_MARKER = 'E2E_EDIT_SKILL:';
|
|
const ASSERT_MODEL_SPEC_SKILLS_MARKER = 'E2E_ASSERT_MODEL_SPEC_SKILLS';
|
|
const ASSERT_PROVIDER_FILE_MARKER = 'E2E_ASSERT_PROVIDER_FILE:';
|
|
const REPLY_MARKER = 'E2E_REPLY:';
|
|
const COUNTED_REPLY_MARKER = 'E2E_COUNTED_REPLY:';
|
|
const SLOW_REPLY_MARKER = 'E2E_SLOW_REPLY:';
|
|
const SLOW_COUNTED_REPLY_MARKER = 'E2E_SLOW_COUNTED_REPLY:';
|
|
const RESUME_ICON_REPLY_MARKER = 'E2E_RESUME_ICON_REPLY:';
|
|
const FORCED_ERROR_MARKER = 'E2E_FORCED_ERROR:';
|
|
const MARKDOWN_REPLY_MARKER = 'E2E_MARKDOWN_REPLY';
|
|
const CREATE_FILE_AUTHORING_FINAL_TEXT = 'E2E file authoring complete';
|
|
const EDIT_FILE_AUTHORING_FINAL_TEXT = 'E2E file edit complete';
|
|
const MODEL_SPEC_SKILL_ASSERTION_FINAL_TEXT = 'E2E model spec skill assertion passed';
|
|
const PROVIDER_FILE_ASSERTION_FINAL_TEXT = 'E2E provider file assertion passed';
|
|
const SLOW_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_SLOW_CHUNK_DELAY_MS) || 35;
|
|
const SLOW_REPLY_CHUNKS = 160;
|
|
const RESUME_ICON_CHUNK_DELAY_MS = Number(process.env.MOCK_LLM_RESUME_ICON_CHUNK_DELAY_MS) || 60;
|
|
const RESUME_ICON_REPLY_CHUNKS = 240;
|
|
const CREATE_FILE_TOOL_NAME = 'create_file';
|
|
const EDIT_FILE_TOOL_NAME = 'edit_file';
|
|
const BASH_TOOL_NAME = 'bash_tool';
|
|
const SKILL_TOOL_NAME = 'skill';
|
|
const CREATE_SKILL_TOOL_CALL_ID = 'call_e2e_create_skill';
|
|
const EDIT_SKILL_TOOL_CALL_ID = 'call_e2e_edit_skill';
|
|
const MODEL_SPEC_ACCESSIBLE_SKILL = 'e2e-model-spec-allowed';
|
|
const MODEL_SPEC_MISSING_SKILL = 'e2e-model-spec-missing';
|
|
const MODEL_SPEC_INACCESSIBLE_SKILL = 'e2e-model-spec-inaccessible';
|
|
const ALWAYS_APPLY_BODY_MARKER = 'E2E_ALWAYS_APPLY_BODY_MARKER';
|
|
const SKILL_DESCRIPTION =
|
|
'Use this skill to verify LibreChat skill file authoring in mock end-to-end tests.';
|
|
const EDITED_SKILL_DESCRIPTION =
|
|
'Use this edited skill to verify LibreChat skill file authoring in mock end-to-end tests.';
|
|
const countedReplies = new Map();
|
|
const slowCountedReplies = new Map();
|
|
|
|
function messageType(message) {
|
|
if (typeof message.getType === 'function') {
|
|
return message.getType();
|
|
}
|
|
if (typeof message._getType === 'function') {
|
|
return message._getType();
|
|
}
|
|
return message.role || message.type || '';
|
|
}
|
|
|
|
function getContentText(content) {
|
|
if (typeof content === 'string') {
|
|
return content;
|
|
}
|
|
if (!Array.isArray(content)) {
|
|
return '';
|
|
}
|
|
return content
|
|
.map((part) => {
|
|
if (typeof part === 'string') {
|
|
return part;
|
|
}
|
|
if (part && typeof part === 'object' && typeof part.text === 'string') {
|
|
return part.text;
|
|
}
|
|
return '';
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
function getLatestUserText(messages) {
|
|
const message = getLatestUserMessage(messages);
|
|
return message ? getContentText(message.content) : '';
|
|
}
|
|
|
|
function getLatestUserMessage(messages) {
|
|
if (!Array.isArray(messages)) {
|
|
return null;
|
|
}
|
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
const message = messages[index];
|
|
if (!message) {
|
|
continue;
|
|
}
|
|
const type = messageType(message);
|
|
if (type === 'human' || type === 'user') {
|
|
return message;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getRequestedSkillName(text, marker) {
|
|
const markerIndex = text.indexOf(marker);
|
|
if (markerIndex === -1) {
|
|
return '';
|
|
}
|
|
const afterMarker = text.slice(markerIndex + marker.length);
|
|
return afterMarker.match(/[a-z0-9][a-z0-9-]*/)?.[0] ?? '';
|
|
}
|
|
|
|
function getMarkerValue(text, marker) {
|
|
const markerIndex = text.indexOf(marker);
|
|
if (markerIndex === -1) {
|
|
return '';
|
|
}
|
|
return (
|
|
text
|
|
.slice(markerIndex + marker.length)
|
|
.trim()
|
|
.split(/\s+/, 1)[0] ?? ''
|
|
);
|
|
}
|
|
|
|
function collectToolNames(agents) {
|
|
const names = new Set();
|
|
const add = (name) => {
|
|
if (typeof name === 'string' && name) {
|
|
names.add(name);
|
|
}
|
|
};
|
|
for (const agent of agents ?? []) {
|
|
if (!agent) {
|
|
continue;
|
|
}
|
|
for (const tool of agent.tools ?? []) {
|
|
add(tool?.name);
|
|
}
|
|
for (const def of agent.toolDefinitions ?? []) {
|
|
add(def?.name);
|
|
}
|
|
if (agent.toolRegistry && typeof agent.toolRegistry.keys === 'function') {
|
|
for (const name of agent.toolRegistry.keys()) {
|
|
add(name);
|
|
}
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
function collectAdditionalInstructions(agents) {
|
|
return (agents ?? [])
|
|
.map((agent) =>
|
|
typeof agent?.additional_instructions === 'string' ? agent.additional_instructions : '',
|
|
)
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
function collectSkillPrimeMessages(messages) {
|
|
return (messages ?? [])
|
|
.filter((message) => message?.additional_kwargs?.source === 'skill')
|
|
.map((message) => ({
|
|
name: message.additional_kwargs.skillName,
|
|
trigger: message.additional_kwargs.trigger,
|
|
content: getContentText(message.content),
|
|
}));
|
|
}
|
|
|
|
function collectProviderFileNames(value, names = new Set()) {
|
|
if (value == null) {
|
|
return names;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) {
|
|
collectProviderFileNames(item, names);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
if (typeof value !== 'object') {
|
|
return names;
|
|
}
|
|
|
|
if (value.type === 'input_file' && typeof value.filename === 'string') {
|
|
names.add(value.filename);
|
|
}
|
|
|
|
if (value.type === 'file' && typeof value.file?.filename === 'string') {
|
|
names.add(value.file.filename);
|
|
}
|
|
|
|
if (value.type === 'document' && typeof value.context === 'string') {
|
|
const match = value.context.match(/File:\s*"([^"]+)"/);
|
|
if (match?.[1]) {
|
|
names.add(match[1]);
|
|
}
|
|
}
|
|
|
|
for (const child of Object.values(value)) {
|
|
collectProviderFileNames(child, names);
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
function providerFileAssertionResponses({ messages, text }) {
|
|
const filename = getMarkerValue(text, ASSERT_PROVIDER_FILE_MARKER);
|
|
if (!filename) {
|
|
return null;
|
|
}
|
|
|
|
const latestUserMessage = getLatestUserMessage(messages);
|
|
const providerFileNames = collectProviderFileNames(latestUserMessage?.content);
|
|
if (providerFileNames.has(filename)) {
|
|
return {
|
|
responses: [`${PROVIDER_FILE_ASSERTION_FINAL_TEXT}: ${filename}`],
|
|
};
|
|
}
|
|
|
|
return {
|
|
responses: [
|
|
`E2E provider file assertion failed: expected ${filename}; saw ${
|
|
Array.from(providerFileNames).join(', ') || 'no provider files'
|
|
}`,
|
|
],
|
|
};
|
|
}
|
|
|
|
function replyResponses(text) {
|
|
if (text.includes(MARKDOWN_REPLY_MARKER)) {
|
|
return {
|
|
responses: [
|
|
[
|
|
'## E2E markdown heading',
|
|
'',
|
|
'**E2E bold text**',
|
|
'',
|
|
'- E2E list item',
|
|
'',
|
|
'```javascript',
|
|
'const e2eSyntaxHighlight = "ok";',
|
|
'```',
|
|
].join('\n'),
|
|
],
|
|
};
|
|
}
|
|
|
|
const errorName = getMarkerValue(text, FORCED_ERROR_MARKER);
|
|
if (errorName) {
|
|
return {
|
|
responses: [`E2E forced error prelude ${errorName}`],
|
|
thrownError: `E2E forced stream error ${errorName}`,
|
|
};
|
|
}
|
|
|
|
const replyName = getMarkerValue(text, REPLY_MARKER);
|
|
if (replyName) {
|
|
return {
|
|
responses: [`E2E reply ${replyName}`],
|
|
};
|
|
}
|
|
|
|
const countedName = getMarkerValue(text, COUNTED_REPLY_MARKER);
|
|
if (countedName) {
|
|
const count = (countedReplies.get(countedName) ?? 0) + 1;
|
|
countedReplies.set(countedName, count);
|
|
return {
|
|
responses: [`E2E counted reply ${countedName} #${count}`],
|
|
};
|
|
}
|
|
|
|
const slowName = getMarkerValue(text, SLOW_REPLY_MARKER);
|
|
if (slowName) {
|
|
const chunks = Array.from(
|
|
{ length: SLOW_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E slow reply ${slowName} ${chunks}`],
|
|
sleep: SLOW_CHUNK_DELAY_MS,
|
|
};
|
|
}
|
|
|
|
const slowCountedName = getMarkerValue(text, SLOW_COUNTED_REPLY_MARKER);
|
|
if (slowCountedName) {
|
|
const count = (slowCountedReplies.get(slowCountedName) ?? 0) + 1;
|
|
slowCountedReplies.set(slowCountedName, count);
|
|
const chunks = Array.from(
|
|
{ length: SLOW_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E slow counted reply ${slowCountedName} #${count} ${chunks}`],
|
|
sleep: SLOW_CHUNK_DELAY_MS,
|
|
};
|
|
}
|
|
|
|
const resumeIconName = getMarkerValue(text, RESUME_ICON_REPLY_MARKER);
|
|
if (resumeIconName) {
|
|
const chunks = Array.from(
|
|
{ length: RESUME_ICON_REPLY_CHUNKS },
|
|
(_, index) => `chunk-${String(index).padStart(3, '0')}`,
|
|
).join(' ');
|
|
return {
|
|
responses: [`E2E resume icon reply ${resumeIconName} ${chunks}`],
|
|
sleep: RESUME_ICON_CHUNK_DELAY_MS,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Attaches synthetic usage_metadata on a final empty chunk (the OpenAI
|
|
* streaming pattern) so token-usage SSE events flow end to end in mock runs.
|
|
*/
|
|
class UsageEmittingFakeChatModel extends FakeChatModel {
|
|
async *_streamResponseChunks(messages, options, runManager) {
|
|
let outputChars = 0;
|
|
for await (const chunk of super._streamResponseChunks(messages, options, runManager)) {
|
|
outputChars += typeof chunk.text === 'string' ? chunk.text.length : 0;
|
|
yield chunk;
|
|
}
|
|
const inputChars = (messages ?? []).reduce(
|
|
(sum, message) => sum + getContentText(message?.content).length,
|
|
0,
|
|
);
|
|
const input_tokens = Math.max(1, Math.ceil(inputChars / 4));
|
|
const output_tokens = Math.max(1, Math.ceil(outputChars / 4));
|
|
yield new ChatGenerationChunk({
|
|
text: '',
|
|
message: new AIMessageChunk({
|
|
content: '',
|
|
usage_metadata: { input_tokens, output_tokens, total_tokens: input_tokens + output_tokens },
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
function overrideModel({ graph, responses, sleep, toolCalls, thrownError }) {
|
|
if (!thrownError) {
|
|
graph.overrideModel = new UsageEmittingFakeChatModel({
|
|
responses,
|
|
sleep: sleep ?? CHUNK_DELAY_MS,
|
|
emitCustomEvent: true,
|
|
toolCalls,
|
|
});
|
|
return;
|
|
}
|
|
|
|
class ThrowingFakeChatModel extends FakeChatModel {
|
|
async *_streamResponseChunks(messages, options, runManager) {
|
|
yield* super._streamResponseChunks(
|
|
messages,
|
|
{ ...options, thrownErrorString: thrownError },
|
|
runManager,
|
|
);
|
|
}
|
|
}
|
|
|
|
graph.overrideModel = new ThrowingFakeChatModel({
|
|
responses,
|
|
sleep: sleep ?? CHUNK_DELAY_MS,
|
|
emitCustomEvent: true,
|
|
toolCalls,
|
|
});
|
|
}
|
|
|
|
function modelSpecSkillAssertionResponses({ agents, messages, toolNames }) {
|
|
const failures = [];
|
|
const additionalInstructions = collectAdditionalInstructions(agents);
|
|
const skillPrimeMessages = collectSkillPrimeMessages(messages);
|
|
const alwaysApplyPrime = skillPrimeMessages.find(
|
|
(message) => message.name === MODEL_SPEC_ACCESSIBLE_SKILL && message.trigger === 'always-apply',
|
|
);
|
|
|
|
if (!toolNames.has(SKILL_TOOL_NAME)) {
|
|
failures.push(`${SKILL_TOOL_NAME} tool was not advertised`);
|
|
}
|
|
if (!additionalInstructions.includes(MODEL_SPEC_ACCESSIBLE_SKILL)) {
|
|
failures.push(`${MODEL_SPEC_ACCESSIBLE_SKILL} was not present in the model-visible catalog`);
|
|
}
|
|
if (additionalInstructions.includes(MODEL_SPEC_MISSING_SKILL)) {
|
|
failures.push(`${MODEL_SPEC_MISSING_SKILL} leaked into the model-visible catalog`);
|
|
}
|
|
if (additionalInstructions.includes(MODEL_SPEC_INACCESSIBLE_SKILL)) {
|
|
failures.push(`${MODEL_SPEC_INACCESSIBLE_SKILL} leaked into the model-visible catalog`);
|
|
}
|
|
if (!alwaysApplyPrime) {
|
|
failures.push(`${MODEL_SPEC_ACCESSIBLE_SKILL} was not always-apply primed`);
|
|
} else if (!alwaysApplyPrime.content.includes(ALWAYS_APPLY_BODY_MARKER)) {
|
|
failures.push(`${MODEL_SPEC_ACCESSIBLE_SKILL} always-apply body was missing its marker`);
|
|
}
|
|
if (skillPrimeMessages.some((message) => message.name === MODEL_SPEC_MISSING_SKILL)) {
|
|
failures.push(`${MODEL_SPEC_MISSING_SKILL} was unexpectedly primed`);
|
|
}
|
|
if (skillPrimeMessages.some((message) => message.name === MODEL_SPEC_INACCESSIBLE_SKILL)) {
|
|
failures.push(`${MODEL_SPEC_INACCESSIBLE_SKILL} was unexpectedly primed`);
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
return {
|
|
responses: [`E2E model spec skill assertion failed: ${failures.join('; ')}`],
|
|
};
|
|
}
|
|
return {
|
|
responses: [`${MODEL_SPEC_SKILL_ASSERTION_FINAL_TEXT}: ${MODEL_SPEC_ACCESSIBLE_SKILL}`],
|
|
};
|
|
}
|
|
|
|
function buildSkillBody(skillName) {
|
|
return `---
|
|
name: ${skillName}
|
|
description: ${SKILL_DESCRIPTION}
|
|
---
|
|
|
|
# ${skillName}
|
|
|
|
Created by the Playwright mock e2e suite to verify host file authoring without code execution.`;
|
|
}
|
|
|
|
function buildCreateSkillArgs(skillName) {
|
|
return {
|
|
file_path: `skills/${skillName}/SKILL.md`,
|
|
content: buildSkillBody(skillName),
|
|
overwrite: false,
|
|
};
|
|
}
|
|
|
|
function buildEditSkillArgs(skillName) {
|
|
return {
|
|
file_path: `skills/${skillName}/SKILL.md`,
|
|
old_text: `description: ${SKILL_DESCRIPTION}`,
|
|
new_text: `description: ${EDITED_SKILL_DESCRIPTION}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pick the fake-model script for a skill file-authoring turn. The graph runs two
|
|
* model turns: turn 1 streams the (empty) preamble and emits the tool call, the
|
|
* tool node writes the SKILL.md, then turn 2 streams the final text. The guards
|
|
* assert the feature advertised the host file-authoring tool and did NOT enable
|
|
* code execution.
|
|
*/
|
|
function fileAuthoringResponses(operation, toolNames) {
|
|
if (!toolNames.has(operation.toolName)) {
|
|
return {
|
|
responses: [`E2E file authoring unavailable: ${operation.toolName} was not advertised.`],
|
|
};
|
|
}
|
|
if (toolNames.has(BASH_TOOL_NAME)) {
|
|
return {
|
|
responses: [`E2E file authoring unavailable: ${BASH_TOOL_NAME} was unexpectedly advertised.`],
|
|
};
|
|
}
|
|
return {
|
|
responses: ['', `${operation.finalText}: ${operation.skillName}`],
|
|
toolCalls: [
|
|
{
|
|
id: operation.toolCallId,
|
|
name: operation.toolName,
|
|
args: operation.args,
|
|
type: 'tool_call',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function resolveResponses({ agents, messages, text, toolNames }) {
|
|
const reply = replyResponses(text);
|
|
if (reply) {
|
|
return reply;
|
|
}
|
|
|
|
const providerFileAssertion = providerFileAssertionResponses({ messages, text });
|
|
if (providerFileAssertion) {
|
|
return providerFileAssertion;
|
|
}
|
|
|
|
if (text.includes(ASSERT_MODEL_SPEC_SKILLS_MARKER)) {
|
|
return modelSpecSkillAssertionResponses({ agents, messages, toolNames });
|
|
}
|
|
|
|
const createSkillName = getRequestedSkillName(text, CREATE_SKILL_MARKER);
|
|
if (createSkillName) {
|
|
return fileAuthoringResponses(
|
|
{
|
|
skillName: createSkillName,
|
|
toolName: CREATE_FILE_TOOL_NAME,
|
|
toolCallId: CREATE_SKILL_TOOL_CALL_ID,
|
|
finalText: CREATE_FILE_AUTHORING_FINAL_TEXT,
|
|
args: buildCreateSkillArgs(createSkillName),
|
|
},
|
|
toolNames,
|
|
);
|
|
}
|
|
|
|
const editSkillName = getRequestedSkillName(text, EDIT_SKILL_MARKER);
|
|
if (editSkillName) {
|
|
return fileAuthoringResponses(
|
|
{
|
|
skillName: editSkillName,
|
|
toolName: EDIT_FILE_TOOL_NAME,
|
|
toolCallId: EDIT_SKILL_TOOL_CALL_ID,
|
|
finalText: EDIT_FILE_AUTHORING_FINAL_TEXT,
|
|
args: buildEditSkillArgs(editSkillName),
|
|
},
|
|
toolNames,
|
|
);
|
|
}
|
|
|
|
return { responses: [MOCK_REPLY] };
|
|
}
|
|
|
|
/** @type {import('@librechat/api').TestRunHook} */
|
|
module.exports = function fakeModelHook(run, context) {
|
|
const graph = run?.Graph;
|
|
if (!graph || typeof graph.overrideTestModel !== 'function') {
|
|
console.warn('[e2e] fake-model hook: run.Graph.overrideTestModel unavailable');
|
|
return;
|
|
}
|
|
|
|
const text = getLatestUserText(context?.messages);
|
|
const toolNames = collectToolNames(context?.agents);
|
|
const { responses, sleep, toolCalls, thrownError } = resolveResponses({
|
|
agents: context?.agents,
|
|
messages: context?.messages,
|
|
text,
|
|
toolNames,
|
|
});
|
|
overrideModel({ graph, responses, sleep, toolCalls, thrownError });
|
|
};
|