diff --git a/.env.example b/.env.example
index 1a6a27ee35b..42b88f61bcf 100644
--- a/.env.example
+++ b/.env.example
@@ -729,6 +729,10 @@ TTS_API_KEY=
#==================================================#
# LIBRECHAT_CODE_API_KEY=
+
+# Advertise the immutable per-conversation code-environment decision protocol.
+# Enable only after every LibreChat API replica runs a version that supports protocol v1.
+# CODE_ENVIRONMENT_DECISION_VERSION=1
# LIBRECHAT_CODE_BASEURL=
# Current self-hosted Code Interpreter deployments use per-user LibreChat JWTs outside local mode.
# Configure the matching public verifier on Code Interpreter; see:
diff --git a/.github/workflows/agents-integration-tests.yml b/.github/workflows/agents-integration-tests.yml
index 102e7db995e..f015ee618ac 100644
--- a/.github/workflows/agents-integration-tests.yml
+++ b/.github/workflows/agents-integration-tests.yml
@@ -1,9 +1,12 @@
-name: Agents Integration Tests
-
-# Runs the packages/api `src/agents/**` integration specs (e.g. the durable HITL
-# checkpointer and cross-replica subagent delivery against real MongoDB and Redis). These
-# are `*.integration.spec.ts`, which `test:ci` deliberately excludes โ without this
-# job they run nowhere and their regressions guard nothing.
+name: Integration Tests
+
+# Runs every packages/api `*.integration.spec.ts` / `*.integration.test.ts` suite (e.g. the
+# durable HITL checkpointer and cross-replica subagent delivery against real MongoDB and
+# Redis, the admin config secret registry against a real Config collection, MCP flows
+# against in-process servers). `test:ci` deliberately excludes them, and the Redis-backed
+# `*.cache_integration` / `*.stream_integration` suites run in cache-integration-tests.yml โ
+# without this job they run nowhere and their regressions guard nothing. Selection is by
+# suffix, not folder, so a suite added anywhere under src is picked up.
on:
pull_request:
branches:
@@ -11,9 +14,9 @@ on:
- dev
- dev-staging
- release/*
- # The suite builds and consumes data-provider and data-schemas and imports
+ # The suites build and consume data-provider and data-schemas and import
# across packages/api (the build-cache keys below hash all three src trees),
- # so it must re-run on any of them โ not just src/agents.
+ # so they must re-run on any of them.
paths:
- 'packages/api/src/**'
- 'packages/api/package.json'
@@ -106,9 +109,9 @@ jobs:
if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api
- - name: Run agents integration tests
+ - name: Run integration tests
working-directory: packages/api
env:
NODE_ENV: test
REDIS_URI: redis://127.0.0.1:6379
- run: npm run test:agents-integration
+ run: npm run test:integration
diff --git a/CONTEXT.md b/CONTEXT.md
index 1fa12027743..061c21700be 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -3,6 +3,7 @@
- **Scheduled run admission**: The claimed-occurrence phase that rehydrates the owner, validates current schedule policy and agent reachability, resolves files and MCP readiness, and only then competes for durable generation capacity. It owns cancellation and lease revalidation until a generation slot is reserved; a slow or failed readiness check never occupies generation capacity.
- **Attached code environment**: A principal- or deployment-authorized stateful workspace owned by an outbound `librechat-code` worker on a user-chosen machine or VM. LibreChat selects it and enforces approval policy, Code API authenticates and dispatches to it, and the worker's local sandbox and capability flags remain the final execution ceiling. The environment interface is runtime-neutral: native SRT, WSL2, Docker/NsJail, and future adapters expose the same workspace operations without leaking host paths or runtime configuration into agent tools.
+- **Conversation code-environment decision**: The immutable choice established by a conversation's first accepted submission between validated attached workspaces and continuing without an attached environment. Agent defaults and recent workspace preferences may suggest a draft choice, but only the persisted conversation decision authorizes attached workspace tool registration; later turns, retries, resumes, and alternate ingresses cannot upgrade or replace it.
- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
- **Agent execution context**: runtime-only, transport-free state rehydrated beside an Agent run envelope. It contains the authenticated user, application configuration, normalized request metadata, and resolved conversation facts needed by initialization, but never Express request/response objects or serialized credentials.
- **Agent execution host**: the protocol-neutral module that owns run admission, disconnect cancellation, provider-start fencing, and terminal settlement. Protocol implementations execute behind its callback interface; HTTP adapters retain validation and final stream rendering.
diff --git a/Dockerfile b/Dockerfile
index 53c6ee7cb5a..dc28d9d771e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-# v0.8.8-rc2
+# v0.8.8-rc3
# Base node image
FROM node:24.16.0-alpine AS node
diff --git a/Dockerfile.multi b/Dockerfile.multi
index 43d203fad16..ed38c8d2eb5 100644
--- a/Dockerfile.multi
+++ b/Dockerfile.multi
@@ -1,5 +1,5 @@
# Dockerfile.multi
-# v0.8.8-rc2
+# v0.8.8-rc3
# Set configurable max-old-space-size with default
ARG NODE_MAX_OLD_SPACE_SIZE=6144
diff --git a/README.md b/README.md
index 63e471edde9..4a6b43c6c60 100644
--- a/README.md
+++ b/README.md
@@ -51,30 +51,21 @@
-## ๐ What's New in v0.8.8-rc2
-
-- **Agent run control:** Interrupt an Agent before visible answer text, steer runs with files and quoted excerpts, durably queue follow-ups, and recover saved partial work with **Keep going** or **Answer now**.
-- **Agent activity:** Optional generated labels group reasoning and tool work, fold completed groups into live phase cards, keep generated files visible, summarize multi-step phases, and show the current reasoning direction.
-- **Human-in-the-loop Agents:** Stream up to four related questions, pause for input or tool approval, and resume durably.
-- **Unified Agent Builder:** Configure Skills, MCP, Code Interpreter, orchestration, Programmatic Tool Calling, model-spec controls, and per-tool background and intent settings in one Tools marketplace; Skills can be enabled for standalone runtime authoring without exposing the existing catalog.
-- **Durable Agent automation:** Authenticated Agent Events support bound child actors, expected-action receipts, per-actor mailboxes, event batching, durable human pauses, and automatic detached Actions across built-in stream stores.
-- **Deeper Subagent history:** Browse branch-aware child turns with bounded reasoning and stable live event views, load earlier activity, inspect event details, continue completed child chats, and automatically wake saved parent Agents when detached work settles.
-- **Background tools:** Eligible Code Interpreter, MCP, Plugin, and Action tools can run while an Agent keeps working, with automatic delivery for supported completions and polling controls when needed.
-- **Code Interpreter workflows:** Sandbox images return as viewable artifacts; highly experimental stateful sessions add scoped managed, attached, or personal environments, per-message file downloads, and guarded file-write and command permissions.
-- **Agent extensibility:** Experimental Agent Plugins bundle deployment Skills, MCP servers, and opt-in command hooks; saved Agent teams run as isolated Subagent graphs.
-- **Scheduled Chats (experimental):** Run saved Agents with presets or custom cron, selectable time zones, multi-day weekly cadence, and optional Chat Project destinations.
-- **Memory and context:** Agents can use optionally isolated memory, preserve adaptive context fading across turns, and show categorized current-window usage, tokens, and optional cost.
-- **Editable long pastes:** Long pasted text becomes an editable attachment that can be moved back into the composer; attachment-only turns and reliable Upload as Text downloads are also supported.
-- **Projects, settings, and navigation:** Search conversation titles and message contents, manage project chats, use searchable settings and shortcuts, pin chats, choose clock/week conventions, and navigate faster on mobile.
-- **Sharing and artifacts:** Stable shared links support personal copies; fullscreen previews, Mermaid export, PowerPoint templates, shell scripts, and original Office downloads expand file workflows.
-- **Web search:** Keenable adds keyless search and page fetch, while SearXNG and Tavily gain richer controls and all web-tool egress uses stronger SSRF protection.
-- **Security and authentication:** Default HTTP security headers, opt-in nonce CSP, authenticated local images, per-user Code Interpreter JWTs, stable SAML identity binding, live-session OpenID token refresh, and retired JWT-secret rejection harden deployments.
-- **Models and reasoning:** Added GPT-5.6 with Responses reasoning controls, Claude Fable 5.1, Opus 5, and Sonnet 5, plus Gemini 3.8/3.7/3.6 Flash and Gemini 3.5 Flash-Lite.
-- **Langfuse observability:** Configure encrypted in-app connections, tenant fanout, authenticated gateways, export-decision telemetry, and authorized session links in chats and shared views.
-- **Administration:** Source-aware content filters can audit or block model-bound data, while tenant Insights, delegated configuration, encrypted secrets, and expiring violation scores improve operations.
-- **Streaming and reliability:** Adaptive smoothing, Redis delta batching and failover recovery, automatic generation protocol v2, live MCP catalog refresh, Agent circuit breakers, and DocumentDB support improve long runs and scaled deployments.
-
-Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-rc2).
+## ๐ What's New in v0.8.8-rc3
+
+- **Agent Management API (beta):** Create, discover, update, and delete Agents; manage Agent files and Skills; and authenticate machine clients through deployment-bound OIDC identities while preserving existing role and Agent access controls.
+- **Attached workspaces (highly experimental):** Select or save a per-Agent default workspace for each managed or personal code worker, then let Agents inspect trees, read and search files, author changes, and run Bash with bounded timeouts. Personal workers support bounded self-service enrollment, readiness status, and per-Agent Git identity.
+- **Background tool controls:** Optionally cancel ordinary background tools, including attached Bash, while keeping detached Subagent execution independent.
+- **Code approval controls:** Choose **Ask**, **Allow**, or **Deny** for file writes and command execution where administrators permit it, including a **Full access** mode for trusted attached environments. File Search and Run Code also honor role grants.
+- **Manual context compaction:** Start a summarize-only turn before the context window fills while preserving recent conversation content according to the deployment's summarization policy.
+- **Context Usage:** Inspect dialogue, retained tool traffic, Agent instructions, cache, cost, and runway pressure without double-counting category subsets.
+- **Unified attachments:** Upload once and let LibreChat route content to the model or extracted text, then provision File Search and Code tools only when needed.
+- **Models:** Added GPT-6 Astra for the OpenAI and Agents endpoints, with Responses API routing and tool-call support.
+- **Agent and chat UI:** Unified tool activity, reasoning, search, and Agent workflows; added one draggable Pinned section for chats and favorites, morphing state icons, high-contrast themes, rich-text message copying, clearer sidebar titles, and refined live phase layouts.
+- **Observability:** Export correlated application logs through OpenTelemetry, configure allowlisted Langfuse trace identity and metadata, tag browser diagnostics with client build IDs, and scope Insights to authorized Agents.
+- **Reliability and security:** Strengthened Agent continuation and checkpoint recovery, Redis liveness detection, DocumentDB coordination, OpenID and MCP OAuth sessions, shared-link throttling, tenant isolation, attachment bounds, and upload error handling.
+
+Read the [full v0.8.8-rc3 changelog](https://www.librechat.ai/changelog/v0.8.8-rc3).
# โจ Features
@@ -102,6 +93,8 @@ Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-r
- [Skills](https://www.librechat.ai/docs/features/skills): Create reusable `SKILL.md` instruction bundles for manual, automatic, or always-on agent workflows
- [Agent Plugins](https://www.librechat.ai/docs/features/agent_plugins): Experimentally bundle deployment Skills and MCP servers into startup-loaded packages
- [Subagents](https://www.librechat.ai/docs/features/subagents): Delegate focused work to isolated child agent runs with their own context windows
+ - Agent Management API: Automate Agent, file, and Skill management with deployment-bound OIDC clients
+ - Attached Code Workspaces: Let Agents inspect, search, edit, and run commands in managed or personal workspaces (highly experimental)
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
@@ -126,10 +119,12 @@ Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-r
- Edit, Resubmit, and Continue Messages with Conversation branching
- Create and share prompts with specific users and groups
- [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control
+ - Compact long conversations on demand while preserving recent context
- ๐ฌ **Multimodal & File Interactions**:
- Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini ๐ธ
- Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google ๐๏ธ
+ - Copy messages as formatted rich text for documents, email, and collaboration apps
- ๐ **Multilingual UI**:
- English, ไธญๆ (็ฎไฝ), ไธญๆ (็น้ซ), ุงูุนุฑุจูุฉ, Deutsch, Espaรฑol, Franรงais, Italiano
@@ -142,6 +137,10 @@ Read the [full v0.8.8-rc2 changelog](https://www.librechat.ai/changelog/v0.8.8-r
- ๐จ **Customizable Interface**:
- Customizable Dropdown & Interface that adapts to both power users and newcomers
+ - Light, dark, system, and high-contrast appearance modes
+
+- ๐ **Observability**:
+ - Export traces and logs with OpenTelemetry and connect Langfuse for Agent and model insights
- ๐ **[Resumable Streams](https://www.librechat.ai/docs/features/resumable_streams)**:
- Never lose a response: AI responses automatically reconnect and resume if your connection drops
diff --git a/api/config/index.js b/api/config/index.js
index 79a1c246638..2e3ed6e0181 100644
--- a/api/config/index.js
+++ b/api/config/index.js
@@ -44,6 +44,7 @@ function getActionFlowStateManager(flowsCache) {
if (!actionFlowManager) {
actionFlowManager = new FlowStateManager(flowsCache, {
ttl: Time.ONE_MINUTE * 3,
+ redisScriptExecutor: evalKeyvRedisScript,
});
}
return actionFlowManager;
diff --git a/api/db/indexSync.js b/api/db/indexSync.js
index 85a5524a76a..ad187de1117 100644
--- a/api/db/indexSync.js
+++ b/api/db/indexSync.js
@@ -2,7 +2,7 @@ const mongoose = require('mongoose');
const { MeiliSearch } = require('meilisearch');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider');
-const { isEnabled, FlowStateManager } = require('@librechat/api');
+const { isEnabled, FlowStateManager, evalKeyvRedisScript } = require('@librechat/api');
const { getLogStores } = require('~/cache');
const { batchResetMeiliFlags } = require('./utils');
@@ -372,6 +372,7 @@ async function indexSync() {
const flowManager = new FlowStateManager(flowsCache, {
ttl: 60000 * 10, // 10 minutes TTL for sync operations
+ redisScriptExecutor: evalKeyvRedisScript,
});
// Use a unique flow ID for the sync operation
diff --git a/api/package.json b/api/package.json
index 1288ab39a0d..5f38267f31b 100644
--- a/api/package.json
+++ b/api/package.json
@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
- "version": "v0.8.8-rc2",
+ "version": "v0.8.8-rc3",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "5.1.6",
- "@librechat/agents": "^3.8.5",
+ "@librechat/agents": "^3.8.6",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js
index 1d760423812..cd9c1cd2ae8 100644
--- a/api/server/controllers/agents/__tests__/callbacks.spec.js
+++ b/api/server/controllers/agents/__tests__/callbacks.spec.js
@@ -22,9 +22,8 @@ jest.mock('@librechat/api', () => ({
}
: null,
),
- isCodeSessionToolName: jest.fn((name) =>
- ['execute_code', 'bash_tool', 'read_file'].includes(name),
- ),
+ isCodeArtifactToolOutput: jest.requireActual('@librechat/api').isCodeArtifactToolOutput,
+ isCodeSessionToolName: jest.requireActual('@librechat/api').isCodeSessionToolName,
}));
jest.mock('@librechat/data-schemas', () => ({
diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js
index d6935069fce..e891d6cb44e 100644
--- a/api/server/controllers/agents/__tests__/openai.spec.js
+++ b/api/server/controllers/agents/__tests__/openai.spec.js
@@ -164,6 +164,7 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
ToolEndHandler: jest.fn(),
formatAgentMessages: jest.fn().mockReturnValue({
@@ -177,7 +178,11 @@ jest.mock('@librechat/api', () => ({
createProvisionFilesCallback: () => async () => {},
createAgentExecutionContext: (context) => context,
/** Grants both by default; the capability set is what these specs vary. */
- resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, fileSearch: true })),
+ resolveToolRoleGrants: jest.fn(async () => ({
+ runCode: true,
+ fileSearch: true,
+ webSearch: true,
+ })),
collectReachableAgents: (roots) => {
const agents = [];
const pending = [...roots];
@@ -204,6 +209,8 @@ jest.mock('@librechat/api', () => ({
createRun: jest.fn().mockResolvedValue({
processStream: mockProcessStream,
}),
+ createTerminalRunErrorObserver: (...args) =>
+ jest.requireActual('@librechat/api').createTerminalRunErrorObserver(...args),
applyContextToAgent: (...args) => mockApplyContextToAgent(...args),
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
buildInlineMemoryContext: (...args) => mockBuildInlineMemoryContext(...args),
@@ -214,14 +221,27 @@ jest.mock('@librechat/api', () => ({
buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions),
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
+ resolveConversationCodeEnvironmentDecision: ({
+ requestedMode,
+ requestedSelections,
+ conversation,
+ }) => {
+ const codeWorkspaces = requestedSelections ?? conversation?.codeWorkspaces;
+ return {
+ mode: requestedMode ?? (codeWorkspaces?.length ? 'attached' : 'without_attached'),
+ ...(codeWorkspaces !== undefined && { codeWorkspaces }),
+ };
+ },
createMCPRuntimeRequestBody: ({
messageId,
conversationId,
parentMessageId,
+ codeEnvironmentMode,
codeWorkspaces,
}) => ({
messageId,
conversationId,
+ ...(codeEnvironmentMode !== undefined && { codeEnvironmentMode }),
...(codeWorkspaces !== undefined && { codeWorkspaces }),
...(parentMessageId !== undefined && {
parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000',
@@ -349,7 +369,7 @@ jest.mock('@librechat/api', () => ({
return await execute(execution);
} catch (error) {
executionError = error;
- if (handleExecutionError) return await handleExecutionError(error);
+ if (handleExecutionError) return await handleExecutionError(error, execution?.signal);
throw error;
} finally {
removeCloseListener();
@@ -1037,6 +1057,28 @@ describe('OpenAIChatCompletionController', () => {
});
describe('safe error logging', () => {
+ it('does not classify a client disconnect as an upstream model error', async () => {
+ const api = require('@librechat/api');
+ const { logger } = require('@librechat/data-schemas');
+ const abortError = Object.assign(new Error('request aborted'), { name: 'AbortError' });
+ mockProcessStream.mockImplementationOnce(async () => {
+ const modelCallback = api.createRun.mock.calls
+ .at(-1)[0]
+ .modelCallbacks.find(({ name }) => name === 'librechat-upstream-model-error-tracker');
+ modelCallback.handleLLMError(abortError);
+ res.once.mock.calls.find(([event]) => event === 'close')[1]();
+ throw abortError;
+ });
+
+ await OpenAIChatCompletionController(req, res);
+
+ expect(mockExecution.signal.aborted).toBe(true);
+ expect(logger.error).not.toHaveBeenCalledWith(
+ '[OpenAI API] Upstream model error',
+ expect.anything(),
+ );
+ });
+
it('logs bounded metadata and returns a raw-free provider error', async () => {
const api = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
@@ -1050,15 +1092,30 @@ describe('OpenAIChatCompletionController', () => {
},
});
req.config.filters = { messages: { pii: {} } };
- mockProcessStream.mockRejectedValueOnce(providerError);
+ mockProcessStream.mockImplementationOnce(async () => {
+ const modelCallback = api.createRun.mock.calls
+ .at(-1)[0]
+ .modelCallbacks.find(({ name }) => name === 'librechat-upstream-model-error-tracker');
+ modelCallback.handleLLMError(providerError);
+ throw new Error('graph failed', { cause: providerError });
+ });
await OpenAIChatCompletionController(req, res);
- expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(providerError);
const errorLog = logger.error.mock.calls.find(
- ([message]) => message === '[OpenAI API] Error:',
+ ([message]) => message === '[OpenAI API] Upstream model error',
);
- expect(errorLog).toEqual(['[OpenAI API] Error:', { type: 'Error', status: 502 }]);
+ expect(errorLog).toEqual([
+ '[OpenAI API] Upstream model error',
+ {
+ type: 'Error',
+ status: 502,
+ errorCode: 'UPSTREAM_MODEL_ERROR',
+ errorOrigin: 'model_provider',
+ errorType: '502',
+ traceId: 'a64360db27015f7d7eadebf78a806ac1',
+ },
+ ]);
expect(JSON.stringify(errorLog)).not.toContain(rawValue);
expect(api.createErrorResponse).toHaveBeenCalledWith(
'An error occurred while processing the request',
@@ -1071,7 +1128,9 @@ describe('OpenAIChatCompletionController', () => {
it('streams a raw-free provider error after headers are sent', async () => {
const api = require('@librechat/api');
+ const { logger } = require('@librechat/data-schemas');
const rawValue = 'PRIVATE-OPENAI-STREAM-PAYLOAD';
+ const providerError = new Error(`Provider echoed ${rawValue}`);
api.validateRequest.mockReturnValueOnce({
request: {
model: 'agent-123',
@@ -1083,7 +1142,13 @@ describe('OpenAIChatCompletionController', () => {
res.flushHeaders.mockImplementationOnce(() => {
res.headersSent = true;
});
- mockProcessStream.mockRejectedValueOnce(new Error(`Provider echoed ${rawValue}`));
+ mockProcessStream.mockImplementationOnce(async () => {
+ api.createRun.mock.calls
+ .at(-1)[0]
+ .modelCallbacks.find(({ name }) => name === 'librechat-upstream-model-error-tracker')
+ .handleLLMError(providerError);
+ throw providerError;
+ });
await OpenAIChatCompletionController(req, res);
@@ -1094,6 +1159,13 @@ describe('OpenAIChatCompletionController', () => {
);
expect(JSON.stringify(api.createChunk.mock.calls)).not.toContain(rawValue);
expect(JSON.stringify(api.writeSSE.mock.calls)).not.toContain(rawValue);
+ expect(logger.error).toHaveBeenCalledWith(
+ '[OpenAI API] Upstream model error',
+ expect.objectContaining({
+ errorCode: 'UPSTREAM_MODEL_ERROR',
+ traceId: 'a64360db27015f7d7eadebf78a806ac1',
+ }),
+ );
});
it('preserves the legacy provider error when protection is inactive', async () => {
@@ -1553,6 +1625,7 @@ describe('OpenAIChatCompletionController', () => {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
+ codeEnvironmentMode: 'without_attached',
},
}),
expect.anything(),
@@ -1563,6 +1636,7 @@ describe('OpenAIChatCompletionController', () => {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
+ codeEnvironmentMode: 'without_attached',
},
}),
);
@@ -1574,6 +1648,7 @@ describe('OpenAIChatCompletionController', () => {
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
parentMessageId: 'parent-123',
+ codeEnvironmentMode: 'without_attached',
},
}),
}),
@@ -1600,6 +1675,7 @@ describe('OpenAIChatCompletionController', () => {
expect(requestBody).toEqual({
messageId: 'chatcmpl-mock-nanoid-123',
conversationId: 'conversation-123',
+ codeEnvironmentMode: 'without_attached',
});
expect(requestBody).not.toHaveProperty('parentMessageId');
});
@@ -1781,4 +1857,43 @@ describe('OpenAIChatCompletionController', () => {
);
});
});
+
+ describe('web search role gating', () => {
+ const setCapabilities = (capabilities) => {
+ req.config.endpoints.agents.capabilities = capabilities;
+ };
+
+ const passedResolver = () => {
+ const { initializeAgent } = require('@librechat/api');
+ return initializeAgent.mock.calls[0][0].resolveWebSearchGrant;
+ };
+
+ /** Provider-native search is a model parameter with no capability of its own,
+ * so the resolver is handed over whatever the capabilities โ but it reads
+ * nothing until the initializer finds native search in the built config. */
+ it('hands initializeAgent a grant resolver without reading the role', async () => {
+ const { resolveToolRoleGrants } = require('@librechat/api');
+ setCapabilities([]);
+
+ await OpenAIChatCompletionController(req, res);
+
+ expect(passedResolver()).toEqual(expect.any(Function));
+ expect(resolveToolRoleGrants).not.toHaveBeenCalled();
+ });
+
+ it('resolves the WEB_SEARCH grant against this request when called', async () => {
+ const { resolveToolRoleGrants } = require('@librechat/api');
+ resolveToolRoleGrants.mockResolvedValueOnce({
+ runCode: true,
+ fileSearch: true,
+ webSearch: false,
+ });
+ setCapabilities([]);
+
+ await OpenAIChatCompletionController(req, res);
+
+ await expect(passedResolver()()).resolves.toBe(false);
+ expect(resolveToolRoleGrants).toHaveBeenCalledWith(expect.objectContaining({ req }));
+ });
+ });
});
diff --git a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
index dae2a4861a1..90b9c89a330 100644
--- a/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
+++ b/api/server/controllers/agents/__tests__/request.partialDisconnect.spec.js
@@ -65,6 +65,10 @@ jest.mock('@librechat/api', () => ({
resolveTitleTiming: jest.fn(() => 'immediate'),
resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor,
resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces,
+ shouldPersistCodeWorkspaceInitializationError:
+ jest.requireActual('@librechat/api').shouldPersistCodeWorkspaceInitializationError,
+ getSafeErrorMetadata: jest.requireActual('@librechat/api').getSafeErrorMetadata,
+ getSafeErrorText: jest.requireActual('@librechat/api').getSafeErrorText,
GenerationJobManager: mockGenerationJobManager,
getReferencedQuotes: jest.fn(() => null),
cleanupMCPRequestContext: jest.fn(),
diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
index b81a06fffac..a7594950bb7 100644
--- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
+++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js
@@ -55,6 +55,7 @@ function wonGenerationClaim(overrides = {}) {
}
const mockCheckAndIncrementPendingRequest = jest.fn();
+const mockGetFailedTurnTraceFields = jest.fn();
const mockDecrementPendingRequest = jest.fn();
const mockGetViolationInfo = jest.fn(() => ({
type: 'concurrent',
@@ -274,8 +275,15 @@ jest.mock('@librechat/api', () => ({
resolveTitleTiming: jest.fn(() => 'immediate'),
resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor,
resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces,
+ AttachmentStorageError: jest.requireActual('@librechat/api').AttachmentStorageError,
+ encodeAndFormatImages: jest.requireActual('@librechat/api').encodeAndFormatImages,
getCodeWorkspaceSelectionErrorDetails:
jest.requireActual('@librechat/api').getCodeWorkspaceSelectionErrorDetails,
+ shouldPersistCodeWorkspaceInitializationError:
+ jest.requireActual('@librechat/api').shouldPersistCodeWorkspaceInitializationError,
+ getSafeErrorMetadata: jest.requireActual('@librechat/api').getSafeErrorMetadata,
+ getSafeErrorText: jest.requireActual('@librechat/api').getSafeErrorText,
+ getFailedTurnTraceFields: (...args) => mockGetFailedTurnTraceFields(...args),
GenerationJobManager: mockGenerationJobManager,
getReferencedQuotes: jest.fn((quotes) => {
if (!Array.isArray(quotes)) {
@@ -333,11 +341,13 @@ jest.mock('@librechat/api', () => ({
messageId,
conversationId,
parentMessageId,
+ codeEnvironmentMode,
codeWorkspaces,
}) => ({
messageId,
conversationId,
parentMessageId,
+ ...(codeEnvironmentMode !== undefined ? { codeEnvironmentMode } : {}),
...(codeWorkspaces !== undefined ? { codeWorkspaces } : {}),
}),
}));
@@ -400,6 +410,7 @@ jest.mock('~/server/services/Agents/triggers', () => ({
}));
const AgentController = require('../request');
+const { AttachmentStorageError, encodeAndFormatImages } = require('@librechat/api');
const { ErrorTypes } = require('librechat-data-provider');
const { disposeClient: mockDisposeClient } = require('~/server/cleanup');
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
@@ -1425,6 +1436,53 @@ describe('ResumableAgentController resume metadata', () => {
expect(requestBody.messageId).not.toBe(req.body.messageId);
});
+ it('pins a normalized code-environment decision before provider execution', async () => {
+ let signalProviderStarted;
+ const providerStarted = new Promise((resolve) => {
+ signalProviderStarted = resolve;
+ });
+ const sendMessage = jest.fn(() => {
+ signalProviderStarted();
+ return new Promise(() => {});
+ });
+ const initializeClient = jest.fn(async ({ req }) => {
+ req.body.codeEnvironmentMode = 'without_attached';
+ delete req.body.codeWorkspaces;
+ return { client: { options: {}, sendMessage } };
+ });
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Continue without my attached machine.',
+ messageId: 'incoming-client-message',
+ parentMessageId: 'previous-response',
+ conversationId: 'conversation-123',
+ endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
+ },
+ config: {},
+ };
+
+ await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);
+ await providerStarted;
+
+ const initialRequestBody =
+ mockGenerationJobManager.createJob.mock.calls[0][3].initialMetadata.mcpRequestBody;
+ expect(initialRequestBody).not.toHaveProperty('codeEnvironmentMode');
+ expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
+ 'conversation-123',
+ {
+ mcpRequestBody: {
+ ...initialRequestBody,
+ codeEnvironmentMode: 'without_attached',
+ },
+ },
+ 1000,
+ );
+ expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
+ sendMessage.mock.invocationCallOrder[0],
+ );
+ });
+
it('uses the effective overridden conversation in the MCP request body', async () => {
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after MCP discovery'));
const req = {
@@ -2602,6 +2660,134 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
});
+ it('names the failure class at the initialization boundary', async () => {
+ const initializeClient = jest.fn().mockRejectedValue(new AttachmentStorageError());
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Describe the attached image.',
+ messageId: 'user-msg',
+ clientRequestId: 'req-abc',
+ conversationId: 'conversation-123',
+ endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
+ },
+ config: {},
+ };
+ const res = createResumableResponse();
+
+ await AgentController(req, res, jest.fn(), initializeClient, null);
+
+ const [message, ...metadata] = mockLogger.error.mock.calls.find((call) =>
+ String(call[0]).startsWith('[ResumableAgentController] Initialization error:'),
+ );
+ expect(metadata).toEqual([]);
+ expect(message).toContain(
+ 'AttachmentStorageError: An attached file could not be read from storage.',
+ );
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
+ 'conversation-123',
+ 'An attached file could not be read from storage. Try again or upload it again.',
+ 1000,
+ expect.objectContaining({ beforeErrorPublication: expect.any(Function) }),
+ );
+ });
+
+ it('redacts a signed storage URL an initialization failure carries into the log', async () => {
+ const signedUrl =
+ 'https://minio.example.com/bucket/image.png?X-Amz-Credential=secret&X-Amz-Signature=signed';
+ const initializeClient = jest
+ .fn()
+ .mockRejectedValue(new Error(`AccessDenied reading ${signedUrl}`));
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Describe the attached image.',
+ messageId: 'user-msg',
+ clientRequestId: 'req-abc',
+ conversationId: 'conversation-123',
+ endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
+ },
+ config: {},
+ };
+
+ await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);
+
+ const [message, ...metadata] = mockLogger.error.mock.calls.find((call) =>
+ String(call[0]).startsWith('[ResumableAgentController] Initialization error:'),
+ );
+ expect(metadata).toEqual([]);
+ expect(message).toContain('AccessDenied reading https://minio.example.com/[redacted]');
+ expect(message).not.toContain('X-Amz-Signature');
+ expect(JSON.stringify(mockLogger.error.mock.calls)).not.toContain(signedUrl);
+ });
+
+ it('publishes a safe image storage failure during generation and releases the request', async () => {
+ const signedUrl =
+ 'https://minio.example.com/bucket/image.png?X-Amz-Credential=secret&X-Amz-Signature=signed';
+ const storageError = Object.assign(new Error(`Access denied for ${signedUrl}`), {
+ code: 'AccessDenied',
+ statusCode: 403,
+ });
+ const getDownloadStream = jest.fn().mockRejectedValue(storageError);
+ const file = {
+ file_id: 'image-1',
+ source: 's3',
+ filepath: signedUrl,
+ storageKey: 'images/user/image.png',
+ height: 10,
+ width: 10,
+ };
+ const req = {
+ user: { id: 'user-123' },
+ body: {
+ text: 'Describe the attached image.',
+ messageId: 'user-msg',
+ conversationId: 'conversation-123',
+ endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
+ },
+ config: {},
+ };
+ const client = {
+ options: {},
+ sendMessage: jest.fn(() =>
+ encodeAndFormatImages(
+ req,
+ [file],
+ {},
+ { getStrategyFunctions: () => ({ getDownloadStream }) },
+ ),
+ ),
+ };
+ const initializeClient = jest.fn().mockResolvedValue({ client });
+
+ await AgentController(req, createResumableResponse(), jest.fn(), initializeClient, null);
+ await nextTick();
+
+ const safeError = new AttachmentStorageError();
+ expect(getDownloadStream).toHaveBeenCalledWith(req, file.storageKey);
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ '[ResumableAgentController] Generation error for conversation-123:',
+ safeError,
+ );
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
+ 'conversation-123',
+ safeError.message,
+ 1000,
+ expect.objectContaining({ beforeErrorPublication: expect.any(Function) }),
+ );
+ expect(mockSaveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'user-123' }),
+ expect.objectContaining({ error: true, text: safeError.message }),
+ expect.any(Object),
+ );
+ expect(mockDecrementPendingRequest).toHaveBeenCalledWith('user-123');
+ expect(mockDisposeClient).toHaveBeenCalledWith(client);
+ expect(JSON.stringify(mockGenerationJobManager.completeJob.mock.calls)).not.toContain(
+ signedUrl,
+ );
+ expect(JSON.stringify(mockSaveMessage.mock.calls)).not.toContain(signedUrl);
+ });
+
it('returns a typed recovery conflict before acknowledging generation startup', async () => {
const recoveryError = new Error('Attached resources could not be restored');
recoveryError.code = ErrorTypes.RESOURCE_RECOVERY_REQUIRED;
@@ -3638,6 +3824,83 @@ describe('ResumableAgentController resume metadata', () => {
expect(savedIds).not.toContain('user-message_');
});
+ it('points a failed turn at the trace of the run that failed', async () => {
+ const traceFields = {
+ langfuseSampled: true,
+ langfuseDestinationIds: ['destination-1'],
+ langfuseRunId: 'server-response-uuid',
+ };
+ mockGetFailedTurnTraceFields.mockResolvedValue(traceFields);
+ const serverUserMessage = {
+ messageId: 'server-user',
+ parentMessageId: 'prior-response',
+ conversationId,
+ sender: 'User',
+ text: 'Hello with a removed model.',
+ isCreatedByUser: true,
+ };
+ const client = {
+ options: {},
+ sendMessage: jest.fn(async (_text, options) => {
+ options.onStart(serverUserMessage, 'server-response-uuid');
+ client.run = {};
+ throw new Error('failed inside the run');
+ }),
+ };
+
+ await AgentController(
+ createFailedRequest(),
+ createResumableResponse(),
+ jest.fn(),
+ jest.fn().mockResolvedValue({ client }),
+ null,
+ );
+ await flushBackgroundGeneration();
+
+ const errorRow = mockSaveMessage.mock.calls
+ .map(([, message]) => message)
+ .find((message) => message.messageId === 'server-user_');
+ expect(mockGetFailedTurnTraceFields).toHaveBeenCalledWith(expect.anything(), {
+ messageId: 'server-user_',
+ runId: 'server-response-uuid',
+ runCreated: true,
+ });
+ expect(errorRow).toMatchObject({ error: true, isCreatedByUser: false, ...traceFields });
+ });
+
+ it('tells the trace lookup when a failure came before the run was created', async () => {
+ mockGetFailedTurnTraceFields.mockResolvedValue({});
+ const client = {
+ options: {},
+ sendMessage: jest.fn(async (_text, options) => {
+ options.onStart(
+ { messageId: 'server-user', conversationId, isCreatedByUser: true, text: 'Hi' },
+ 'server-response-uuid',
+ );
+ throw new Error('failed before the run');
+ }),
+ };
+
+ await AgentController(
+ createFailedRequest(),
+ createResumableResponse(),
+ jest.fn(),
+ jest.fn().mockResolvedValue({ client }),
+ null,
+ );
+ await flushBackgroundGeneration();
+
+ const errorRow = mockSaveMessage.mock.calls
+ .map(([, message]) => message)
+ .find((message) => message.messageId === 'server-user_');
+ expect(mockGetFailedTurnTraceFields).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ runCreated: false }),
+ );
+ expect(errorRow).toMatchObject({ error: true });
+ expect(errorRow).not.toHaveProperty('langfuseSampled');
+ });
+
it('does not overwrite an existing response row', async () => {
mockGetMessages.mockResolvedValue([{ _id: 'already-saved' }]);
@@ -3653,6 +3916,36 @@ describe('ResumableAgentController resume metadata', () => {
expect(mockSaveConvo).not.toHaveBeenCalled();
});
+ it('does not persist a first turn whose code environment decision was rejected', async () => {
+ const res = createResumableResponse();
+ mockGenerationJobManager.claimGeneration.mockImplementation(
+ async (_userId, _clientRequestId, streamId, claimedConversationId) =>
+ wonGenerationClaim({ streamId, conversationId: claimedConversationId }),
+ );
+ const req = createFailedRequest({
+ conversationId: undefined,
+ clientRequestId: 'invalid-code-decision',
+ codeEnvironmentMode: 'attached',
+ codeWorkspaces: undefined,
+ });
+ const workspaceError = Object.assign(new Error('Choose an attached workspace'), {
+ code: ErrorTypes.CODE_WORKSPACE_UNAVAILABLE,
+ reason: 'required',
+ status: 409,
+ statusCode: 409,
+ });
+
+ await AgentController(req, res, jest.fn(), jest.fn().mockRejectedValue(workspaceError), null);
+
+ expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.stringContaining(ErrorTypes.CODE_WORKSPACE_UNAVAILABLE),
+ 1000,
+ );
+ expect(mockSaveMessage).not.toHaveBeenCalled();
+ expect(mockSaveConvo).not.toHaveBeenCalled();
+ });
+
it('creates the conversation row for a failed first turn', async () => {
const res = createResumableResponse();
mockGenerationJobManager.claimGeneration.mockImplementation(
@@ -3670,15 +3963,18 @@ describe('ResumableAgentController resume metadata', () => {
modelOptions: { model: 'gpt-4o' },
chatProjectId: '507f1f77bcf86cd799439011',
},
+ codeEnvironmentMode: 'attached',
+ codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }],
+ });
+ const initializeClient = jest.fn().mockImplementation(async ({ req: request }) => {
+ request._codeEnvironmentDecision = {
+ mode: 'attached',
+ codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }],
+ };
+ throw new Error('model unavailable');
});
- await AgentController(
- req,
- res,
- jest.fn(),
- jest.fn().mockRejectedValue(new Error('model unavailable')),
- null,
- );
+ await AgentController(req, res, jest.fn(), initializeClient, null);
const mintedConversationId = res.json.mock.calls[0][0].conversationId;
expect(mockSaveMessage).toHaveBeenCalledWith(
@@ -3696,6 +3992,8 @@ describe('ResumableAgentController resume metadata', () => {
endpoint: 'azureOpenAI',
model: 'gpt-4o',
chatProjectId: '507f1f77bcf86cd799439011',
+ codeEnvironmentMode: 'attached',
+ codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }],
}),
expect.objectContaining({ initialAgentId: null }),
);
diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js
index 635e6099dfb..1cbac69ccb5 100644
--- a/api/server/controllers/agents/__tests__/responses.unit.spec.js
+++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js
@@ -187,6 +187,7 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
ToolEndHandler: jest.fn(),
formatAgentMessages: jest.fn().mockReturnValue({
@@ -200,7 +201,11 @@ jest.mock('@librechat/api', () => ({
createProvisionFilesCallback: () => async () => {},
createAgentExecutionContext: (context) => context,
/** Grants both by default; the capability set is what these specs vary. */
- resolveToolRoleGrants: jest.fn(async () => ({ runCode: true, fileSearch: true })),
+ resolveToolRoleGrants: jest.fn(async () => ({
+ runCode: true,
+ fileSearch: true,
+ webSearch: true,
+ })),
SAFE_CONVERSATION_TITLE: 'New Chat',
resolveConversationTitle: (...args) => mockResolveConversationTitle(...args),
/** Pass-through: the controller strips UI-only activity-label parts
@@ -228,20 +233,35 @@ jest.mock('@librechat/api', () => ({
createRun: jest.fn().mockResolvedValue({
processStream: jest.fn().mockResolvedValue(undefined),
}),
+ createTerminalRunErrorObserver: (...args) =>
+ jest.requireActual('@librechat/api').createTerminalRunErrorObserver(...args),
buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions),
applyContextToAgent: (...args) => mockApplyContextToAgent(...args),
buildRunToolSet: jest.fn().mockReturnValue(new Set()),
AgentRunEnvelopeError: MockAgentRunEnvelopeError,
createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args),
+ resolveConversationCodeEnvironmentDecision: ({
+ requestedMode,
+ requestedSelections,
+ conversation,
+ }) => {
+ const codeWorkspaces = requestedSelections ?? conversation?.codeWorkspaces;
+ return {
+ mode: requestedMode ?? (codeWorkspaces?.length ? 'attached' : 'without_attached'),
+ ...(codeWorkspaces !== undefined && { codeWorkspaces }),
+ };
+ },
getCodeWorkspaceSelections: jest.fn(),
createMCPRuntimeRequestBody: ({
messageId,
conversationId,
parentMessageId,
+ codeEnvironmentMode,
codeWorkspaces,
}) => ({
messageId,
conversationId,
+ ...(codeEnvironmentMode !== undefined && { codeEnvironmentMode }),
...(codeWorkspaces !== undefined && { codeWorkspaces }),
...(parentMessageId !== undefined && {
parentMessageId: parentMessageId ?? '00000000-0000-0000-0000-000000000000',
@@ -417,7 +437,7 @@ jest.mock('@librechat/api', () => ({
return await execute(execution);
} catch (error) {
executionError = error;
- if (handleExecutionError) return await handleExecutionError(error);
+ if (handleExecutionError) return await handleExecutionError(error, execution?.signal);
throw error;
} finally {
removeCloseListener();
@@ -608,6 +628,38 @@ describe('createResponse controller', () => {
},
);
+ it.each([false, true])(
+ 'persists the normalized no-attached decision atomically: stream=%s',
+ async (stream) => {
+ const api = require('@librechat/api');
+ const db = require('~/models');
+ api.getCodeWorkspaceSelections.mockReturnValueOnce([
+ { environmentId: 'machine', workspaceId: 'stale-project' },
+ ]);
+ api.validateResponseRequest.mockReturnValueOnce({
+ request: {
+ model: 'agent-123',
+ input: 'Hello',
+ stream,
+ store: true,
+ code_environment_mode: 'without_attached',
+ },
+ });
+
+ await createResponse(req, res);
+
+ expect(db.saveConvo).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ codeEnvironmentMode: 'without_attached',
+ }),
+ expect.anything(),
+ );
+ expect(db.saveConvo.mock.calls.at(-1)[1]).not.toHaveProperty('codeWorkspaces');
+ expect(api.getCodeWorkspaceSelections).not.toHaveBeenCalled();
+ },
+ );
+
it('enrolls, starts, and settles the remote execution lifecycle', async () => {
await createResponse(req, res);
@@ -902,6 +954,7 @@ describe('createResponse controller', () => {
requestBody: {
messageId: 'resp_mock-123',
conversationId: expect.any(String),
+ codeEnvironmentMode: 'without_attached',
},
}),
expect.anything(),
@@ -1638,6 +1691,29 @@ describe('createResponse controller', () => {
});
describe('safe error logging', () => {
+ it('does not classify a client disconnect as an upstream model error', async () => {
+ const api = require('@librechat/api');
+ const { logger } = require('@librechat/data-schemas');
+ const abortError = Object.assign(new Error('request aborted'), { name: 'AbortError' });
+ api.createRun.mockImplementationOnce(async (options) => ({
+ processStream: jest.fn(async () => {
+ options.modelCallbacks
+ .find(({ name }) => name === 'librechat-upstream-model-error-tracker')
+ .handleLLMError(abortError);
+ res.once.mock.calls.find(([event]) => event === 'close')[1]();
+ throw abortError;
+ }),
+ }));
+
+ await createResponse(req, res);
+
+ expect(mockExecution.signal.aborted).toBe(true);
+ expect(logger.error).not.toHaveBeenCalledWith(
+ '[Responses API] Upstream model error',
+ expect.anything(),
+ );
+ });
+
it('logs bounded metadata and returns a raw-free provider error', async () => {
const api = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
@@ -1655,7 +1731,6 @@ describe('createResponse controller', () => {
await createResponse(req, res);
- expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(providerError);
const errorLog = logger.error.mock.calls.find(
([message]) => message === '[Responses API] Error:',
);
@@ -1670,6 +1745,50 @@ describe('createResponse controller', () => {
expect(JSON.stringify(api.sendResponsesErrorResponse.mock.calls)).not.toContain(rawValue);
});
+ it.each([false, true])(
+ 'classifies a terminal model callback failure and correlates its trace: stream=%s',
+ async (stream) => {
+ const api = require('@librechat/api');
+ const { logger } = require('@librechat/data-schemas');
+ const rawValue = 'PRIVATE-RESPONSES-UPSTREAM-PAYLOAD';
+ const providerError = Object.assign(new Error(`Provider echoed ${rawValue}`), {
+ code: 'ERR_REMOTE',
+ response: { status: 503, data: { prompt: rawValue } },
+ });
+ req.config.filters = { messages: { pii: {} } };
+ api.validateResponseRequest.mockReturnValueOnce({
+ request: { model: 'agent-123', input: 'Hello', stream },
+ });
+ api.createRun.mockImplementationOnce(async (options) => ({
+ processStream: jest.fn(async () => {
+ options.modelCallbacks
+ .find(({ name }) => name === 'librechat-upstream-model-error-tracker')
+ .handleLLMError(providerError);
+ throw new Error('graph failed', { cause: providerError });
+ }),
+ }));
+
+ await createResponse(req, res);
+
+ const errorLog = logger.error.mock.calls.find(
+ ([message]) => message === '[Responses API] Upstream model error',
+ );
+ expect(errorLog).toEqual([
+ '[Responses API] Upstream model error',
+ {
+ type: 'Error',
+ status: 503,
+ errorCode: 'UPSTREAM_MODEL_ERROR',
+ errorOrigin: 'model_provider',
+ errorType: '503',
+ traceId: 'da34f2d846b1b1b770afe89e670770d5',
+ },
+ ]);
+ expect(JSON.stringify(errorLog)).not.toContain(rawValue);
+ expect(JSON.stringify(errorLog)).not.toContain('ERR_REMOTE');
+ },
+ );
+
it('preserves the legacy provider error when protection is inactive', async () => {
const api = require('@librechat/api');
const rawValue = 'LEGACY-RESPONSES-PROVIDER-ERROR';
@@ -2327,4 +2446,43 @@ describe('createResponse controller', () => {
);
});
});
+
+ describe('web search role gating', () => {
+ const setCapabilities = (capabilities) => {
+ req.config.endpoints.agents.capabilities = capabilities;
+ };
+
+ const passedResolver = () => {
+ const { initializeAgent } = require('@librechat/api');
+ return initializeAgent.mock.calls[0][0].resolveWebSearchGrant;
+ };
+
+ /** Provider-native search is a model parameter with no capability of its own,
+ * so the resolver is handed over whatever the capabilities โ but it reads
+ * nothing until the initializer finds native search in the built config. */
+ it('hands initializeAgent a grant resolver without reading the role', async () => {
+ const { resolveToolRoleGrants } = require('@librechat/api');
+ setCapabilities([]);
+
+ await createResponse(req, res);
+
+ expect(passedResolver()).toEqual(expect.any(Function));
+ expect(resolveToolRoleGrants).not.toHaveBeenCalled();
+ });
+
+ it('resolves the WEB_SEARCH grant against this request when called', async () => {
+ const { resolveToolRoleGrants } = require('@librechat/api');
+ resolveToolRoleGrants.mockResolvedValueOnce({
+ runCode: true,
+ fileSearch: true,
+ webSearch: false,
+ });
+ setCapabilities([]);
+
+ await createResponse(req, res);
+
+ await expect(passedResolver()()).resolves.toBe(false);
+ expect(resolveToolRoleGrants).toHaveBeenCalledWith(expect.objectContaining({ req }));
+ });
+ });
});
diff --git a/api/server/controllers/agents/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js
index dde2514f66b..d83ef745ed7 100644
--- a/api/server/controllers/agents/__tests__/resume.spec.js
+++ b/api/server/controllers/agents/__tests__/resume.spec.js
@@ -2461,6 +2461,33 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
await flush();
});
+ it('enforces the current fingerprint while retaining the rolling-deploy digest', async () => {
+ const { computeAgentRequestFingerprint, computeLegacyAgentRequestFingerprint } =
+ jest.requireActual('@librechat/api');
+ const pausedBody = {
+ endpoint: 'agents',
+ agent_id: AGENT_ID,
+ codeEnvironmentMode: 'attached',
+ codeWorkspaces: [{ environmentId: 'machine-a', workspaceId: 'project-a' }],
+ };
+ const job = makeToolApprovalJob();
+ job.metadata.pendingAction.requestFingerprint =
+ computeLegacyAgentRequestFingerprint(pausedBody);
+ job.metadata.pendingAction.requestFingerprintV2 = computeAgentRequestFingerprint(pausedBody);
+ mockGenerationJobManager.getJob.mockResolvedValue(job);
+
+ const res = await post(
+ approveBody({
+ codeEnvironmentMode: 'attached',
+ codeWorkspaces: [{ environmentId: 'machine-a', workspaceId: 'project-b' }],
+ }),
+ );
+
+ expect(res.status).toBe(403);
+ expect(res.body.error).toMatch(/different agent configuration/i);
+ expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
+ });
+
it('403 when the resume sends a different promptPrefix than the paused config', async () => {
const { computeAgentRequestFingerprint } = jest.requireActual('@librechat/api');
const job = makeToolApprovalJob();
diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js
index 770688c6018..6c295b6568a 100644
--- a/api/server/controllers/agents/callbacks.js
+++ b/api/server/controllers/agents/callbacks.js
@@ -26,6 +26,7 @@ const {
createBackgroundCodeResultHandler: createCodeHarvestHandler,
HOST_FILE_AUTHORING_ARTIFACT_KEY,
isCodeSessionToolName,
+ isCodeArtifactToolOutput,
getModelRefusalInfo,
shouldSignalSandboxStart,
getToolInputValidationDetails,
@@ -40,10 +41,6 @@ function isHostFileAuthoringArtifact(artifact) {
return artifact?.[HOST_FILE_AUTHORING_ARTIFACT_KEY] === true;
}
-function isCodeArtifactToolOutput(output) {
- return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact);
-}
-
function getAttachmentOwnership(metadata) {
const agentId = metadata?.executingAgentId ?? metadata?.agentId ?? metadata?.agent_id;
const stepId = metadata?.stepId;
diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js
index 58fc1824d77..e37693d986c 100644
--- a/api/server/controllers/agents/client.js
+++ b/api/server/controllers/agents/client.js
@@ -43,8 +43,8 @@ const {
buildPendingAction,
toClientPendingAction,
captureCodeExecutionApprovalBinding,
- getCodeWorkspaceSelections,
computeAgentRequestFingerprint,
+ computeLegacyAgentRequestFingerprint,
getRunDiscoveredTools,
captureResumeModelParameters,
pickResumeContext,
@@ -169,6 +169,8 @@ const {
createContextMetaPublisher,
selectRunContextMetaToPublish,
resolveToolRoleGrants,
+ createTerminalRunErrorObserver,
+ isAgentRunCancellation,
} = require('@librechat/api');
const {
Run,
@@ -1997,6 +1999,7 @@ class AgentClient extends BaseClient {
collectAttachedCodeEnvironmentPolicySettings(topLevelAgents),
agentsEConfig?.toolApproval?.enabled !== false,
);
+ const codeEnvironmentDecision = this.options.req._codeEnvironmentDecision;
return removeNullishValues(
Object.assign(
@@ -2011,9 +2014,12 @@ class AgentClient extends BaseClient {
imageDetail: this.options.imageDetail,
maxContextTokens: this.maxContextTokens,
codeApprovalMode,
- codeWorkspaces: getCodeWorkspaceSelections(
- collectReachableAgents(topLevelAgents).map((agent) => agent?.codeExecutionContext),
- ),
+ codeEnvironmentMode:
+ codeEnvironmentDecision?.mode ?? this.options.req.body.codeEnvironmentMode,
+ codeWorkspaces:
+ codeEnvironmentDecision != null
+ ? codeEnvironmentDecision.codeWorkspaces
+ : this.options.req.body.codeWorkspaces,
},
// TODO: PARSE OPTIONS BY PROVIDER, MAY CONTAIN SENSITIVE DATA
runOptions,
@@ -3317,6 +3323,7 @@ class AgentClient extends BaseClient {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
+ getRoleByName: db.getRoleByName,
},
);
@@ -4308,7 +4315,11 @@ class AgentClient extends BaseClient {
// Pin the graph-determining request fields so resume can't rebuild this paused
// run on a different agent/tool set (esp. ephemeral agents, whose agent_id is
// undefined so the id guard can't tell two configs apart).
- requestFingerprint: computeAgentRequestFingerprint(this.options.req?.body ?? {}),
+ // Keep the legacy digest in its established field so an old replica can
+ // resume pauses written during a rolling deploy; current replicas also
+ // enforce the stricter code-environment-aware digest below.
+ requestFingerprint: computeLegacyAgentRequestFingerprint(this.options.req?.body ?? {}),
+ requestFingerprintV2: computeAgentRequestFingerprint(this.options.req?.body ?? {}),
// Persist those same fields verbatim so the resume route can REPLAY them โ a
// reload/cross-replica resume can't reconstruct the ephemeral config client-side,
// so the server restores it and rebuilds the same graph (and the fingerprint matches).
@@ -4375,6 +4386,12 @@ class AgentClient extends BaseClient {
let run;
/** @type {Promise<(TAttachment | null)[] | undefined>} */
let memoryPromise;
+ const terminalRunError = createTerminalRunErrorObserver({
+ logger,
+ responseMessageId: this.responseMessageId,
+ source: '[api/server/controllers/agents/client.js #sendCompletion]',
+ genericMessage: '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error',
+ });
const appConfig = this.options.req.config;
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
@@ -4508,6 +4525,9 @@ class AgentClient extends BaseClient {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
+ codeEnvironmentMode:
+ this.options.req.body.codeEnvironmentMode ??
+ this.options.req.resolvedConversation?.codeEnvironmentMode,
codeWorkspaces:
this.options.req.body.codeWorkspaces ??
this.options.req.resolvedConversation?.codeWorkspaces,
@@ -4854,6 +4874,7 @@ class AgentClient extends BaseClient {
modelCallbacks: [
modelBoundCallback,
createAgentMemoryCallback(this.attachmentMemoryContext ?? {}),
+ terminalRunError.modelCallback,
],
// This controller implements the full HITL pause/resume lifecycle (handleRunInterrupt
// persists the pending action; the /resume route rebuilds + continues the run), so it
@@ -4912,6 +4933,7 @@ class AgentClient extends BaseClient {
this.buildDetachedSubagentUsageRecorder(balanceConfig, transactionsConfig),
),
subagentTasks: this.options.subagentTasks,
+ runFiles: this.options.runFiles,
}).then((createdRun) => {
if (!createdRun) {
throw new Error('Failed to create run');
@@ -5075,7 +5097,7 @@ class AgentClient extends BaseClient {
type: ContentTypes.ERROR,
[ContentTypes.ERROR]: err.message,
});
- } else if (abortController.signal.aborted) {
+ } else if (isAgentRunCancellation(err, abortController.signal)) {
logger.debug(
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user',
{ conversationId: this.conversationId, ...getSafeErrorMetadata(err) },
@@ -5100,10 +5122,7 @@ class AgentClient extends BaseClient {
},
);
} else {
- logger.error(
- '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type',
- getSafeErrorMetadata(err),
- );
+ terminalRunError.log(err, abortController.signal);
const videoError = resolveGoogleVideoError({
error: err,
provider: this.options.agent?.provider,
@@ -5113,16 +5132,19 @@ class AgentClient extends BaseClient {
type: ContentTypes.ERROR,
[ContentTypes.ERROR]:
videoError ??
- getUserFacingRequestError(
- 'An error occurred while processing the request',
- err,
- this.options.req.config,
+ terminalRunError.getUserFacingError(err, () =>
+ getUserFacingRequestError(
+ 'An error occurred while processing the request',
+ err,
+ this.options.req.config,
+ ),
),
});
}
} finally {
/** An aborted/erroring run can still have completed compaction before
* the failure; retain that model-visible state for actor reconciliation. */
+ await this.options.runFiles?.close();
this.eventActorSummary =
getLatestEventActorSummary(this.contentParts) ?? this.eventActorSummary;
/** A run that never came to exist has no state of its own: keep the
@@ -5237,6 +5259,12 @@ class AgentClient extends BaseClient {
let config;
/** @type {ReturnType} */
let run;
+ const terminalRunError = createTerminalRunErrorObserver({
+ logger,
+ responseMessageId: this.responseMessageId,
+ source: '[api/server/controllers/agents/client.js #resumeCompletion]',
+ genericMessage: '[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error',
+ });
const appConfig = this.options.req.config;
const balanceConfig = getBalanceConfig(appConfig);
const transactionsConfig = getTransactionsConfig(appConfig);
@@ -5284,6 +5312,9 @@ class AgentClient extends BaseClient {
messageId: this.responseMessageId,
conversationId: this.conversationId,
parentMessageId: this.parentMessageId,
+ codeEnvironmentMode:
+ this.options.req.body.codeEnvironmentMode ??
+ this.options.req.resolvedConversation?.codeEnvironmentMode,
codeWorkspaces:
this.options.req.body.codeWorkspaces ??
this.options.req.resolvedConversation?.codeWorkspaces,
@@ -5613,7 +5644,11 @@ class AgentClient extends BaseClient {
run = await createRun({
agents,
conversationId: this.conversationId,
- modelCallbacks: [modelBoundCallback, attachmentMemoryCallback],
+ modelCallbacks: [
+ modelBoundCallback,
+ attachmentMemoryCallback,
+ terminalRunError.modelCallback,
+ ],
// State (messages, tool calls) is rehydrated from the checkpoint by
// run.resume; createRun only needs the agents to rebuild the graph.
messages: [],
@@ -5664,6 +5699,7 @@ class AgentClient extends BaseClient {
this.buildDetachedSubagentUsageRecorder(balanceConfig, transactionsConfig),
),
subagentTasks: this.options.subagentTasks,
+ runFiles: this.options.runFiles,
});
if (!run) {
@@ -5755,7 +5791,7 @@ class AgentClient extends BaseClient {
);
throw err;
}
- if (abortController.signal.aborted) {
+ if (isAgentRunCancellation(err, abortController.signal)) {
logger.debug(
'[api/server/controllers/agents/client.js #resumeCompletion] Aborted by user',
{
@@ -5773,20 +5809,20 @@ class AgentClient extends BaseClient {
{ conversationId: this.conversationId },
);
} else {
- logger.error(
- '[api/server/controllers/agents/client.js #resumeCompletion] Unhandled error',
- getSafeErrorMetadata(err),
- );
+ terminalRunError.log(err, abortController.signal);
this.contentParts.push({
type: ContentTypes.ERROR,
- [ContentTypes.ERROR]: getUserFacingRequestError(
- 'An error occurred while resuming the request',
- err,
- appConfig,
+ [ContentTypes.ERROR]: terminalRunError.getUserFacingError(err, () =>
+ getUserFacingRequestError(
+ 'An error occurred while resuming the request',
+ err,
+ appConfig,
+ ),
),
});
}
} finally {
+ await this.options.runFiles?.close();
this.eventActorSummary =
getLatestEventActorSummary(this.contentParts) ?? this.eventActorSummary;
/** A run that never came to exist has no state of its own: keep the
diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js
index 3aa60dd3260..54b869ca885 100644
--- a/api/server/controllers/agents/client.test.js
+++ b/api/server/controllers/agents/client.test.js
@@ -78,18 +78,46 @@ describe('AgentClient code approval persistence', () => {
},
req: {
body: { codeApprovalMode: 'acceptEdits' },
+ _codeEnvironmentDecision: {
+ mode: 'attached',
+ codeWorkspaces: [
+ { environmentId: 'attached-vm', workspaceId: 'project-a' },
+ { environmentId: 'team-vm', workspaceId: 'project-b' },
+ ],
+ },
config: { endpoints: { [EModelEndpoint.agents]: {} } },
},
};
expect(client.getSaveOptions()).toMatchObject({
codeApprovalMode: 'acceptEdits',
+ codeEnvironmentMode: 'attached',
codeWorkspaces: [
{ environmentId: 'attached-vm', workspaceId: 'project-a' },
{ environmentId: 'team-vm', workspaceId: 'project-b' },
],
});
});
+
+ it('does not combine a normalized no-attached mode with stale request selections', () => {
+ const client = Object.create(AgentClient.prototype);
+ client.agentConfigs = new Map();
+ client.options = {
+ endpoint: EModelEndpoint.agents,
+ agent: { id: 'attached-agent' },
+ req: {
+ body: {
+ codeWorkspaces: [{ environmentId: 'attached-vm', workspaceId: 'stale-project' }],
+ },
+ _codeEnvironmentDecision: { mode: 'without_attached' },
+ config: { endpoints: { [EModelEndpoint.agents]: {} } },
+ },
+ };
+
+ const saveOptions = client.getSaveOptions();
+ expect(saveOptions.codeEnvironmentMode).toBe('without_attached');
+ expect(saveOptions).not.toHaveProperty('codeWorkspaces');
+ });
});
function deferred() {
@@ -2858,6 +2886,164 @@ describe('AgentClient - startup telemetry', () => {
);
});
+ it('classifies a terminal chat-model failure without logging provider content', async () => {
+ jest.clearAllMocks();
+ const { logger } = require('@librechat/data-schemas');
+ const { traceIdForMessage } = require('@librechat/api');
+ const privateValue = 'PRIVATE-UPSTREAM-PROVIDER-CONTENT';
+ const providerError = Object.assign(new Error(), {
+ code: 'InternalServerException',
+ response: {
+ status: 500,
+ headers: { authorization: privateValue },
+ data: { prompt: privateValue },
+ },
+ });
+ Object.defineProperties(providerError, {
+ name: {
+ get() {
+ throw new Error(`Provider echoed ${privateValue}`);
+ },
+ },
+ message: {
+ get() {
+ throw new Error(`Provider echoed ${privateValue}`);
+ },
+ },
+ });
+ const abortController = new AbortController();
+ const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
+ mockCreateRun.mockImplementation(async (options) => {
+ const tracker = options.modelCallbacks.find(
+ (callback) => callback.name === 'librechat-upstream-model-error-tracker',
+ );
+ return {
+ Graph: null,
+ processStream: jest.fn(async () => {
+ tracker.handleLLMError(providerError, 'model-run');
+ abortController.abort();
+ throw providerError;
+ }),
+ getCalibrationRatio: jest.fn(() => 0),
+ };
+ });
+ mockIsHITLEnabled.mockReturnValue(false);
+ const client = new AgentClient({
+ req: {
+ user: { id: 'user-123' },
+ body: {},
+ config: {
+ endpoints: { [EModelEndpoint.agents]: {} },
+ filters: { messages: { pii: {} } },
+ },
+ _resumableStreamId: 'conversation-upstream-error',
+ },
+ res: {},
+ agent: {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: { model: 'gpt-4' },
+ hide_sequential_outputs: false,
+ },
+ endpointTokenConfig: {},
+ eventHandlers: {},
+ contentParts: [],
+ collectedUsage: [],
+ artifactPromises: [],
+ });
+ client.conversationId = 'conversation-upstream-error';
+ client.responseMessageId = 'response-upstream-error';
+ client.parentMessageId = 'parent-upstream-error';
+ client.recordCollectedUsage = jest.fn().mockResolvedValue();
+
+ await client.chatCompletion({ payload: [], abortController });
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[api/server/controllers/agents/client.js #sendCompletion] Upstream model error',
+ {
+ type: 'Error',
+ status: 500,
+ errorCode: 'UPSTREAM_MODEL_ERROR',
+ errorOrigin: 'model_provider',
+ errorType: '500',
+ traceId: traceIdForMessage('response-upstream-error'),
+ },
+ );
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(privateValue);
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('InternalServerException');
+ expect(errorSpy).not.toHaveBeenCalledWith(
+ '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error',
+ expect.anything(),
+ );
+ expect(client.contentParts).toContainEqual({
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]:
+ 'The model provider could not complete this request.\n' +
+ JSON.stringify({ type: 'upstream_model_error', status: 500 }),
+ });
+ errorSpy.mockRestore();
+ });
+
+ it('keeps a later non-provider run failure on the generic error path', async () => {
+ jest.clearAllMocks();
+ const { logger } = require('@librechat/data-schemas');
+ const recoveredProviderError = new Error('recovered provider failure');
+ const checkpointError = new Error('checkpoint failed');
+ const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
+ mockCreateRun.mockImplementation(async (options) => {
+ const tracker = options.modelCallbacks.find(
+ (callback) => callback.name === 'librechat-upstream-model-error-tracker',
+ );
+ return {
+ Graph: null,
+ processStream: jest.fn(async () => {
+ tracker.handleLLMError(recoveredProviderError, 'recovered-model-run');
+ throw checkpointError;
+ }),
+ getCalibrationRatio: jest.fn(() => 0),
+ };
+ });
+ mockIsHITLEnabled.mockReturnValue(false);
+ const client = new AgentClient({
+ req: {
+ user: { id: 'user-123' },
+ body: {},
+ config: { endpoints: { [EModelEndpoint.agents]: {} } },
+ _resumableStreamId: 'conversation-non-provider-error',
+ },
+ res: {},
+ agent: {
+ id: 'agent-123',
+ endpoint: EModelEndpoint.openAI,
+ provider: EModelEndpoint.openAI,
+ model_parameters: { model: 'gpt-4' },
+ hide_sequential_outputs: false,
+ },
+ endpointTokenConfig: {},
+ eventHandlers: {},
+ contentParts: [],
+ collectedUsage: [],
+ artifactPromises: [],
+ });
+ client.conversationId = 'conversation-non-provider-error';
+ client.responseMessageId = 'response-non-provider-error';
+ client.parentMessageId = 'parent-non-provider-error';
+ client.recordCollectedUsage = jest.fn().mockResolvedValue();
+
+ await client.chatCompletion({ payload: [] });
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error',
+ { type: 'Error' },
+ );
+ expect(errorSpy).not.toHaveBeenCalledWith(
+ '[api/server/controllers/agents/client.js #sendCompletion] Upstream model error',
+ expect.anything(),
+ );
+ errorSpy.mockRestore();
+ });
+
it('cancels current attachment persistence after combined admission rejects the run', async () => {
jest.clearAllMocks();
let attachmentLimitError;
@@ -9469,6 +9655,70 @@ describe('AgentClient - resumeCompletion content protection', () => {
errorSpy.mockRestore();
});
+ it('classifies a terminal resumed model failure and preserves redaction', async () => {
+ const { logger } = require('@librechat/data-schemas');
+ const { traceIdForMessage } = require('@librechat/api');
+ const privateValue = 'PRIVATE-RESUMED-UPSTREAM-CONTENT';
+ const providerError = Object.assign(new Error(), {
+ code: 'PROVIDER_INTERNAL',
+ status: 503,
+ });
+ Object.defineProperties(providerError, {
+ name: {
+ get() {
+ throw new Error(`Provider echoed ${privateValue}`);
+ },
+ },
+ message: {
+ get() {
+ throw new Error(`Provider echoed ${privateValue}`);
+ },
+ },
+ });
+ const abortController = new AbortController();
+ const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger);
+ mockCreateRun.mockImplementation(async (options) => {
+ const tracker = options.modelCallbacks.find(
+ (callback) => callback.name === 'librechat-upstream-model-error-tracker',
+ );
+ return {
+ resume: jest.fn(async () => {
+ tracker.handleLLMError(providerError, 'resumed-model-run');
+ abortController.abort();
+ throw providerError;
+ }),
+ getCalibrationRatio: jest.fn(() => 0),
+ };
+ });
+ const context = makeContext(undefined);
+
+ await AgentClient.prototype.resumeCompletion.call(context, {
+ resumeValue: {},
+ abortController,
+ });
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[api/server/controllers/agents/client.js #resumeCompletion] Upstream model error',
+ {
+ type: 'Error',
+ status: 503,
+ errorCode: 'UPSTREAM_MODEL_ERROR',
+ errorOrigin: 'model_provider',
+ errorType: '503',
+ traceId: traceIdForMessage('response-123'),
+ },
+ );
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(privateValue);
+ expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('PROVIDER_INTERNAL');
+ expect(context.contentParts).toContainEqual({
+ type: ContentTypes.ERROR,
+ [ContentTypes.ERROR]:
+ 'The model provider could not complete this request.\n' +
+ JSON.stringify({ type: 'upstream_model_error', status: 503 }),
+ });
+ errorSpy.mockRestore();
+ });
+
it('preserves provider error detail when content protection is disabled', async () => {
const providerMessage = 'Legacy provider detail';
mockCreateRun.mockRejectedValue(new Error(providerMessage));
diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js
index 1ff29ca8744..1c754b38606 100644
--- a/api/server/controllers/agents/openai.js
+++ b/api/server/controllers/agents/openai.js
@@ -67,6 +67,8 @@ const {
executeAgentRun,
waitForAgentExecutionWrites,
resolveToolRoleGrants,
+ resolveConversationCodeEnvironmentDecision,
+ createTerminalRunErrorObserver,
} = require('@librechat/api');
const {
buildSummarizationHandlers,
@@ -232,7 +234,6 @@ function sendErrorResponse(res, statusCode, message, type = 'invalid_request_err
}
function handleExecutionError({ error, res, context, appConfig }) {
- logger.error('[OpenAI API] Error:', getSafeErrorMetadata(error));
const protectionEnabled = hasModelBoundContentProtection(
appConfig?.filters,
appConfig?.messageFilter?.pii,
@@ -378,6 +379,11 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
}
const responseId = `chatcmpl-${nanoid()}`;
+ const terminalRunError = createTerminalRunErrorObserver({
+ logger,
+ responseMessageId: responseId,
+ source: '[OpenAI API]',
+ });
const created = Math.floor(Date.now() / 1000);
/** @type {import('@librechat/api').OpenAIResponseContext} โ key must be `requestId` to match the type used by createChunk/buildNonStreamingResponse */
@@ -430,7 +436,10 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
onSettlementError: (error) => {
logger.error('[OpenAI API] Failed to settle execution:', getSafeErrorMetadata(error));
},
- handleExecutionError: (error) => handleExecutionError({ error, res, context, appConfig }),
+ handleExecutionError: (error, signal) => {
+ terminalRunError.log(error, signal);
+ return handleExecutionError({ error, res, context, appConfig });
+ },
execute: async (execution) => {
if (request.conversation_id != null) {
if (typeof request.conversation_id !== 'string') {
@@ -448,6 +457,12 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
req.resolvedConversation = conversation;
}
+ const codeEnvironmentDecision = resolveConversationCodeEnvironmentDecision({
+ conversationId,
+ requestedMode: request.code_environment_mode,
+ requestedSelections: request.code_workspaces,
+ conversation: req.resolvedConversation,
+ });
const parentMessageId = request.parent_message_id ?? null;
let mcpParentMessageId;
if (
@@ -461,7 +476,8 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
const mcpRequestBody = createMCPRuntimeRequestBody({
messageId: responseId,
conversationId,
- codeWorkspaces: request.code_workspaces ?? req.resolvedConversation?.codeWorkspaces,
+ codeEnvironmentMode: codeEnvironmentDecision.mode,
+ codeWorkspaces: codeEnvironmentDecision.codeWorkspaces,
parentMessageId: mcpParentMessageId,
});
@@ -498,6 +514,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
+ getRoleByName: db.getRoleByName,
};
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
@@ -527,6 +544,12 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
* a tool the loader is about to drop. */
const fileSearchAvailable =
fileSearchCapabilityEnabled && (await toolRoleGrants)?.fileSearch === true;
+ /** Called by `initializeAgent` only when an agent's built provider config
+ * turns native web search on. It reaches the initializer with `runtime`
+ * and no `req`, so this is what lets it join the grants memoized on this
+ * request instead of issuing its own read. */
+ const resolveWebSearchGrant = async () =>
+ (await resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })).webSearch;
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
const accessibleSkillIds = skillsCapabilityEnabled
@@ -593,6 +616,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
}),
codeEnvAvailable,
fileSearchAvailable,
+ resolveWebSearchGrant,
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
statefulSessionsAvailable: enabledCapabilities.has(
@@ -673,6 +697,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
defaultActiveOnShare,
codeEnvAvailable,
fileSearchAvailable,
+ resolveWebSearchGrant,
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
statefulSessionsAvailable: enabledCapabilities.has(
@@ -1128,6 +1153,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
user: { ...createSafeUser(req.user), id: userId },
traceContext: { endpoint: EModelEndpoint.agents },
tenantId: principal.tenantId,
+ modelCallbacks: [terminalRunError.modelCallback],
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),
diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js
index 20578cffd9d..65b92bfec57 100644
--- a/api/server/controllers/agents/request.js
+++ b/api/server/controllers/agents/request.js
@@ -30,6 +30,7 @@ const {
getAttachmentTitleText,
createMCPRuntimeRequestBody,
resolveRunCodeWorkspaces,
+ getSafeErrorText,
isAgentEventRetentionActive,
createAgentEventActorTurn,
createAgentEventActorDetachedActionLifecycle,
@@ -43,6 +44,8 @@ const {
resolveAgentTurnExecutionPlan,
logAgentMemorySnapshot,
getCodeWorkspaceSelectionErrorDetails,
+ shouldPersistCodeWorkspaceInitializationError,
+ getFailedTurnTraceFields,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
@@ -350,6 +353,7 @@ async function saveErrorTurn(
errorText,
liveUserMessage,
liveResponseMessageId,
+ runCreated = false,
sender,
initialAgentId,
},
@@ -465,9 +469,15 @@ async function saveErrorTurn(
throw new Error('Failed user message could not be persisted');
}
}
+ const langfuseTraceFields = await getFailedTurnTraceFields(req.config, {
+ messageId: errorMessageId,
+ runId: liveResponseMessageId,
+ runCreated,
+ });
const savedErrorMessage = await saveMessage(
reqCtx,
{
+ ...langfuseTraceFields,
messageId: errorMessageId,
conversationId,
parentMessageId: errorParentMessageId,
@@ -490,6 +500,7 @@ async function saveErrorTurn(
const agentId = endpointOption?.agent_id ?? req.body?.agent_id;
const chatProjectId = endpointOption?.chatProjectId ?? req.body?.chatProjectId;
const seedConvo = isNewConvo || req.resolvedConversation === null;
+ const codeEnvironmentDecision = req._codeEnvironmentDecision;
const convoFields = seedConvo
? {
...(endpoint != null && { endpoint }),
@@ -501,6 +512,12 @@ async function saveErrorTurn(
...(endpointOption?.spec != null && { spec: endpointOption.spec }),
...(agentId != null && { agent_id: agentId }),
...(typeof chatProjectId === 'string' && chatProjectId.length > 0 && { chatProjectId }),
+ ...(codeEnvironmentDecision?.mode != null && {
+ codeEnvironmentMode: codeEnvironmentDecision.mode,
+ ...(codeEnvironmentDecision.codeWorkspaces != null && {
+ codeWorkspaces: codeEnvironmentDecision.codeWorkspaces,
+ }),
+ }),
}
: {};
await saveConvo(
@@ -1486,6 +1503,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const mcpRequestBody = createMCPRuntimeRequestBody({
messageId: preallocatedResponseMessageId,
conversationId: effectiveConversationId,
+ codeEnvironmentMode: req.body.codeEnvironmentMode,
codeWorkspaces: resolveRunCodeWorkspaces({
conversationId: effectiveConversationId,
requestedSelections: req.body.codeWorkspaces,
@@ -1919,6 +1937,22 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
});
startupTelemetry?.mark('client_initialized');
client = result.client;
+ const normalizedMCPRequestBody = createMCPRuntimeRequestBody({
+ messageId: mcpRequestBody.messageId,
+ conversationId: mcpRequestBody.conversationId,
+ codeEnvironmentMode: req.body.codeEnvironmentMode,
+ codeWorkspaces: req.body.codeWorkspaces,
+ ...(Object.prototype.hasOwnProperty.call(mcpRequestBody, 'parentMessageId') && {
+ parentMessageId: mcpRequestBody.parentMessageId,
+ }),
+ });
+ if (JSON.stringify(normalizedMCPRequestBody) !== JSON.stringify(mcpRequestBody)) {
+ await GenerationJobManager.updateMetadata(
+ streamId,
+ { mcpRequestBody: normalizedMCPRequestBody },
+ jobCreatedAt,
+ );
+ }
if (
typeof client?.options?.agent?.id === 'string' &&
!isEphemeralAgentId(client.options.agent.id)
@@ -3185,6 +3219,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
errorText: generationError,
liveUserMessage: userMessage,
liveResponseMessageId,
+ runCreated: client?.run != null,
sender: client?.sender,
initialAgentId: verifiedInitialAgentId,
}),
@@ -3288,7 +3323,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
);
});
} catch (error) {
- logger.error('[ResumableAgentController] Initialization error:', error);
+ logger.error(`[ResumableAgentController] Initialization error: ${getSafeErrorText(error)}`);
const initializationFailure = getInitializationFailure(error);
const streamStarted = res.headersSent;
try {
@@ -3383,7 +3418,13 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const initializationError = initializationFailure
? JSON.stringify(initializationFailure)
: error.message || 'Failed to start generation';
- const completionPromise = streamStarted
+ const persistInitializationError = shouldPersistCodeWorkspaceInitializationError({
+ streamStarted,
+ isNewConversation: isNewConvo,
+ failureCode: initializationFailure?.code,
+ hasValidatedDecision: req._codeEnvironmentDecision != null,
+ });
+ const completionPromise = persistInitializationError
? GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt, {
beforeErrorPublication: () =>
saveErrorTurn(req, {
diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js
index bccf4d3ddec..b5da862f9cb 100644
--- a/api/server/controllers/agents/responses.js
+++ b/api/server/controllers/agents/responses.js
@@ -18,8 +18,6 @@ const {
createAgentRunEnvelope,
createAgentExecutionContext,
createMCPRuntimeRequestBody,
- getCodeWorkspaceSelections,
- collectReachableAgents,
buildAgentScopedContext,
buildInlineMemoryContext,
buildAgentContextAttachmentsByAgentId,
@@ -84,6 +82,8 @@ const {
executeAgentRun,
waitForAgentExecutionWrites,
resolveToolRoleGrants,
+ resolveConversationCodeEnvironmentDecision,
+ createTerminalRunErrorObserver,
} = require('@librechat/api');
const {
createResponsesToolEndCallback,
@@ -124,7 +124,6 @@ const filterFilesByRemoteAgentAccess = (params) =>
filterFilesByAgentAccess({ ...params, resourceType: ResourceType.REMOTE_AGENT });
function handleExecutionError({ error, res, appConfig }) {
- logger.error('[Responses API] Error:', getSafeErrorMetadata(error));
const protectionEnabled = hasModelBoundContentProtection(
appConfig?.filters,
appConfig?.messageFilter?.pii,
@@ -449,7 +448,14 @@ async function saveResponseOutput(
* @param {object} agent
* @returns {Promise}
*/
-async function saveConversation(req, conversationId, agentId, agent, codeWorkspaces) {
+async function saveConversation(
+ req,
+ conversationId,
+ agentId,
+ agent,
+ codeEnvironmentMode,
+ codeWorkspaces,
+) {
const title = resolveConversationTitle(req, agent?.name || 'Open Responses Conversation');
await db.saveConvo(
{
@@ -462,6 +468,7 @@ async function saveConversation(req, conversationId, agentId, agent, codeWorkspa
conversationId,
endpoint: EModelEndpoint.agents,
agent_id: agentId,
+ codeEnvironmentMode,
...(codeWorkspaces !== undefined && { codeWorkspaces }),
...(title != null && { title }),
model: agent?.model,
@@ -622,6 +629,11 @@ const executeResponse = async (envelope, { req, res }) => {
// Generate IDs
const responseId = generateResponseId();
+ const terminalRunError = createTerminalRunErrorObserver({
+ logger,
+ responseMessageId: responseId,
+ source: '[Responses API]',
+ });
const context = createResponseContext(request, responseId);
logger.debug(
@@ -667,7 +679,10 @@ const executeResponse = async (envelope, { req, res }) => {
onSettlementError: (error) => {
logger.error('[Responses API] Failed to settle execution:', getSafeErrorMetadata(error));
},
- handleExecutionError: (error) => handleExecutionError({ error, res, appConfig }),
+ handleExecutionError: (error, signal) => {
+ terminalRunError.log(error, signal);
+ return handleExecutionError({ error, res, appConfig });
+ },
execute: async (execution) => {
if (request.previous_response_id != null) {
if (typeof request.previous_response_id !== 'string') {
@@ -697,11 +712,18 @@ const executeResponse = async (envelope, { req, res }) => {
}
}
+ const codeEnvironmentDecision = resolveConversationCodeEnvironmentDecision({
+ conversationId,
+ requestedMode: request.code_environment_mode,
+ requestedSelections: request.code_workspaces,
+ conversation: req.resolvedConversation,
+ });
const parentMessageId = null;
const mcpRequestBody = createMCPRuntimeRequestBody({
messageId: responseId,
conversationId,
- codeWorkspaces: request.code_workspaces ?? req.resolvedConversation?.codeWorkspaces,
+ codeEnvironmentMode: codeEnvironmentDecision.mode,
+ codeWorkspaces: codeEnvironmentDecision.codeWorkspaces,
});
const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents];
const ordinaryToolCancellationEnabled =
@@ -749,6 +771,7 @@ const executeResponse = async (envelope, { req, res }) => {
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
+ getRoleByName: db.getRoleByName,
};
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
@@ -778,6 +801,12 @@ const executeResponse = async (envelope, { req, res }) => {
* a tool the loader is about to drop. */
const fileSearchAvailable =
fileSearchCapabilityEnabled && (await toolRoleGrants)?.fileSearch === true;
+ /** Called by `initializeAgent` only when an agent's built provider config
+ * turns native web search on. It reaches the initializer with `runtime`
+ * and no `req`, so this is what lets it join the grants memoized on this
+ * request instead of issuing its own read. */
+ const resolveWebSearchGrant = async () =>
+ (await resolveToolRoleGrants({ req, getRoleByName: db.getRoleByName })).webSearch;
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true;
const accessibleSkillIds = skillsCapabilityEnabled
@@ -844,6 +873,7 @@ const executeResponse = async (envelope, { req, res }) => {
}),
codeEnvAvailable,
fileSearchAvailable,
+ resolveWebSearchGrant,
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
statefulSessionsAvailable: enabledCapabilities.has(
@@ -924,6 +954,7 @@ const executeResponse = async (envelope, { req, res }) => {
defaultActiveOnShare,
codeEnvAvailable,
fileSearchAvailable,
+ resolveWebSearchGrant,
backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background),
toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents),
statefulSessionsAvailable: enabledCapabilities.has(
@@ -1246,6 +1277,7 @@ const executeResponse = async (envelope, { req, res }) => {
user: { ...createSafeUser(req.user), id: userId },
traceContext: { endpoint: EModelEndpoint.agents },
tenantId: principal.tenantId,
+ modelCallbacks: [terminalRunError.modelCallback],
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),
@@ -1333,9 +1365,8 @@ const executeResponse = async (envelope, { req, res }) => {
conversationId,
agentId,
agent,
- getCodeWorkspaceSelections(
- collectReachableAgents(runAgents).map((config) => config.codeExecutionContext),
- ),
+ codeEnvironmentDecision.mode,
+ codeEnvironmentDecision.codeWorkspaces,
);
// Save input messages
@@ -1481,6 +1512,7 @@ const executeResponse = async (envelope, { req, res }) => {
user: { ...createSafeUser(req.user), id: userId },
traceContext: { endpoint: EModelEndpoint.agents },
tenantId: principal.tenantId,
+ modelCallbacks: [terminalRunError.modelCallback],
/** Bills subagent child-run model calls (reported outside the
* streamEvents loop) into the same collectedUsage array. */
subagentUsageSink: createSubagentUsageSink(collectedUsage),
@@ -1572,9 +1604,8 @@ const executeResponse = async (envelope, { req, res }) => {
conversationId,
agentId,
agent,
- getCodeWorkspaceSelections(
- collectReachableAgents(runAgents).map((config) => config.codeExecutionContext),
- ),
+ codeEnvironmentDecision.mode,
+ codeEnvironmentDecision.codeWorkspaces,
);
await saveInputMessages(req, conversationId, inputMessages, agentId);
diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js
index 793e89568b8..284027e51d6 100644
--- a/api/server/controllers/agents/resume.js
+++ b/api/server/controllers/agents/resume.js
@@ -23,6 +23,7 @@ const {
findDisallowedDecisions,
findIncompleteDecisions,
computeAgentRequestFingerprint,
+ computeLegacyAgentRequestFingerprint,
captureAgentCheckpointGeneration,
deleteAgentCheckpoint,
buildAbortedResponseMetadata,
@@ -917,7 +918,13 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
// when the paused action carries a fingerprint (in-flight pauses from before this
// change won't), and recomputed from the resume body's graph-determining fields.
const pinnedFingerprint = pendingAction.requestFingerprint;
- if (pinnedFingerprint && pinnedFingerprint !== computeAgentRequestFingerprint(req.body ?? {})) {
+ const pinnedFingerprintV2 = pendingAction.requestFingerprintV2;
+ const legacyFingerprint = computeLegacyAgentRequestFingerprint(req.body ?? {});
+ const currentFingerprint = computeAgentRequestFingerprint(req.body ?? {});
+ if (
+ (pinnedFingerprint && pinnedFingerprint !== legacyFingerprint) ||
+ (pinnedFingerprintV2 && pinnedFingerprintV2 !== currentFingerprint)
+ ) {
return sendGenerationJson(
res,
403,
@@ -1822,6 +1829,8 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
createMCPRuntimeRequestBody({
messageId: job.metadata.responseMessageId,
conversationId: streamId,
+ codeEnvironmentMode:
+ req.body.codeEnvironmentMode ?? req.resolvedConversation?.codeEnvironmentMode,
codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces,
parentMessageId: job.metadata.userMessage?.messageId ?? Constants.NO_PARENT,
});
diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js
index f728a5bd4d2..e34647cf2b7 100644
--- a/api/server/controllers/agents/v1.js
+++ b/api/server/controllers/agents/v1.js
@@ -38,6 +38,11 @@ const {
isContentTraversalLimitError,
resolveCanonicalFileReferences,
reportLocatorTraversalFailure,
+ isActiveAgentWorkspaceConfiguration,
+ reconcileAgentWorkspaceDefault,
+ resolveAgentWorkspaceRestoreConfiguration,
+ shouldValidateAgentWorkspaceDefaultBinding,
+ validateAgentWorkspaceDefaultBinding,
} = require('@librechat/api');
const {
Time,
@@ -480,13 +485,27 @@ const validateStatefulCodeEnvironment = (
environment,
environmentId,
environmentIdSelected = false,
+ workspaceId,
+ currentWorkspaceId,
+ currentEnvironmentId,
) => {
+ const configuredEnvironments =
+ req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? [];
+ const workspaceValidation = validateAgentWorkspaceDefaultBinding({
+ workspaceId,
+ environmentId,
+ currentWorkspaceId,
+ currentEnvironmentId,
+ environments: configuredEnvironments,
+ });
+ if (!workspaceValidation.valid) {
+ res.status(400).json({ error: workspaceValidation.error });
+ return false;
+ }
if (enabled !== true && !environmentIdSelected) {
return true;
}
if (environmentId != null) {
- const configuredEnvironments =
- req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? [];
const configuredEnvironment = configuredEnvironments.find(
(configured) => configured.id === environmentId,
);
@@ -780,6 +799,7 @@ const createAgentHandler = async (req, res) => {
agentData.stateful_code_environment,
agentData.code_environment_id,
agentData.code_environment_id != null,
+ agentData.code_workspace_id,
)
) {
return;
@@ -1066,7 +1086,7 @@ const updateAgentHandler = async (req, res) => {
_id,
...rest
} = validatedData;
- const updateData = removeNullishValues(rest);
+ let updateData = removeNullishValues(rest);
if (codeEnvironmentIdField !== undefined) {
updateData.code_environment_id = codeEnvironmentIdField;
}
@@ -1079,10 +1099,12 @@ const updateAgentHandler = async (req, res) => {
updateData.stateful_code_sessions !== undefined ||
updateData.stateful_code_environment !== undefined ||
updateData.code_environment_id !== undefined;
+ const includesWorkspaceConfiguration = updateData.code_workspace_id !== undefined;
const includesToolsConfiguration = Array.isArray(updateData.tools);
const includesToolOptionsConfiguration = updateData.tool_options !== undefined;
if (
includesStatefulConfiguration ||
+ includesWorkspaceConfiguration ||
includesToolsConfiguration ||
includesToolOptionsConfiguration
) {
@@ -1094,6 +1116,11 @@ const updateAgentHandler = async (req, res) => {
const codeEnvironmentSelectionChanged =
updateData.code_environment_id !== undefined &&
updateData.code_environment_id !== existingAgent.code_environment_id;
+ updateData = reconcileAgentWorkspaceDefault({
+ update: updateData,
+ request: validatedData,
+ currentEnvironmentId: existingAgent.code_environment_id,
+ });
const statefulConfigurationChanged =
(updateData.stateful_code_sessions !== undefined &&
(updateData.stateful_code_sessions === true) !==
@@ -1106,7 +1133,20 @@ const updateAgentHandler = async (req, res) => {
includesToolsConfiguration &&
updateData.tools.includes(Tools.execute_code) &&
existingAgent.tools?.includes(Tools.execute_code) !== true;
- if (statefulConfigurationChanged || activatesCodeExecution) {
+ const effectiveCodeWorkspaceId =
+ updateData.code_workspace_id ?? existingAgent.code_workspace_id;
+ const selectsWorkspaceDefault =
+ includesWorkspaceConfiguration &&
+ shouldValidateAgentWorkspaceDefaultBinding({
+ workspaceId: effectiveCodeWorkspaceId,
+ environmentId:
+ updateData.code_environment_id === null
+ ? undefined
+ : (updateData.code_environment_id ?? existingAgent.code_environment_id),
+ currentWorkspaceId: existingAgent.code_workspace_id,
+ currentEnvironmentId: existingAgent.code_environment_id,
+ });
+ if (statefulConfigurationChanged || selectsWorkspaceDefault || activatesCodeExecution) {
const effectiveStatefulSessions =
updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions;
const effectiveStatefulEnvironment =
@@ -1123,6 +1163,9 @@ const updateAgentHandler = async (req, res) => {
effectiveStatefulEnvironment,
effectiveCodeEnvironmentId,
codeEnvironmentSelectionChanged,
+ effectiveCodeWorkspaceId,
+ existingAgent.code_workspace_id,
+ existingAgent.code_environment_id,
)
) {
return;
@@ -1422,12 +1465,15 @@ const duplicateAgentHandler = async (req, res) => {
author: userId,
});
if (
+ isActiveAgentWorkspaceConfiguration(newAgentData) &&
!validateStatefulCodeEnvironment(
req,
res,
newAgentData.stateful_code_sessions,
newAgentData.stateful_code_environment,
newAgentData.code_environment_id,
+ false,
+ newAgentData.code_workspace_id,
)
) {
return;
@@ -1829,6 +1875,7 @@ const getListAgentsHandler = async (req, res) => {
limit,
after: cursor,
includeSkillConfig: true,
+ includeExecutionConfig: true,
});
const agents = data?.data ?? [];
@@ -2026,14 +2073,22 @@ const revertAgentVersionHandler = async (req, res) => {
}
const revertVersion = existingAgent.versions?.[version_index];
+ const restoredWorkspaceConfiguration = revertVersion
+ ? resolveAgentWorkspaceRestoreConfiguration({
+ version: revertVersion,
+ current: existingAgent,
+ })
+ : undefined;
if (
- revertVersion &&
+ isActiveAgentWorkspaceConfiguration(restoredWorkspaceConfiguration) &&
!validateStatefulCodeEnvironment(
req,
res,
- revertVersion.stateful_code_sessions,
- revertVersion.stateful_code_environment,
- revertVersion.code_environment_id,
+ restoredWorkspaceConfiguration.stateful_code_sessions,
+ restoredWorkspaceConfiguration.stateful_code_environment,
+ restoredWorkspaceConfiguration.code_environment_id,
+ false,
+ restoredWorkspaceConfiguration.code_workspace_id,
)
) {
return;
diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js
index 91c351f795d..d96af401fc1 100644
--- a/api/server/controllers/agents/v1.spec.js
+++ b/api/server/controllers/agents/v1.spec.js
@@ -316,6 +316,76 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(await Agent.countDocuments()).toBe(0);
});
+ test('rejects a workspace default without an explicit attached environment', async () => {
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: {
+ allowedEnvironments: ['user'],
+ environments: [
+ {
+ id: 'default-vm',
+ name: 'Default VM',
+ type: 'attached',
+ baseURL: 'https://code.example.com/v1',
+ default: true,
+ },
+ ],
+ },
+ },
+ },
+ };
+ mockReq.body = {
+ name: 'Unbound Workspace Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ code_workspace_id: 'project-a',
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Code workspace defaults require an explicit attached code environment',
+ });
+ expect(await Agent.countDocuments()).toBe(0);
+ });
+
+ test('rejects a workspace default for a managed environment', async () => {
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: {
+ allowedEnvironments: ['user'],
+ environments: [
+ {
+ id: 'managed-runtime',
+ name: 'Managed Runtime',
+ type: 'managed',
+ baseURL: 'https://code.example.com/v1',
+ },
+ ],
+ },
+ },
+ },
+ };
+ mockReq.body = {
+ name: 'Managed Workspace Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ code_environment_id: 'managed-runtime',
+ code_workspace_id: 'project-a',
+ };
+
+ await createAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Code workspace defaults require an explicit attached code environment',
+ });
+ expect(await Agent.countDocuments()).toBe(0);
+ });
+
test('should block configured agent instruction content before persistence', async () => {
mockReq.config = {
filters: {
@@ -1605,12 +1675,74 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.code_environment_id).toBeUndefined();
});
+ test('allows a workspace-only update for an existing attached environment', async () => {
+ await Agent.updateOne({ id: existingAgentId }, { code_environment_id: 'attached-vm' });
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: {
+ allowedEnvironments: ['user'],
+ environments: [
+ {
+ id: 'attached-vm',
+ name: 'Attached VM',
+ type: 'attached',
+ baseURL: 'https://bridge.example.com/v1',
+ },
+ ],
+ },
+ },
+ },
+ };
+ mockReq.body = { code_workspace_id: 'project-a' };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(400);
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.code_environment_id).toBe('attached-vm');
+ expect(agentInDb.code_workspace_id).toBe('project-a');
+ });
+
+ test('rejects a workspace-only update without an attached environment', async () => {
+ mockReq.user.id = existingAgentAuthorId.toString();
+ mockReq.params.id = existingAgentId;
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: {
+ allowedEnvironments: ['user'],
+ environments: [
+ {
+ id: 'default-vm',
+ name: 'Default VM',
+ type: 'attached',
+ baseURL: 'https://bridge.example.com/v1',
+ default: true,
+ },
+ ],
+ },
+ },
+ },
+ };
+ mockReq.body = { code_workspace_id: 'project-a' };
+
+ await updateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ const agentInDb = await Agent.findOne({ id: existingAgentId });
+ expect(agentInDb.code_workspace_id).toBeUndefined();
+ });
+
test('allows disabling stateful sessions after the configured environment is removed', async () => {
await Agent.updateOne(
{ id: existingAgentId },
{
stateful_code_sessions: true,
code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
},
);
mockReq.user.id = existingAgentAuthorId.toString();
@@ -1628,6 +1760,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
mockReq.body = {
stateful_code_sessions: false,
code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
};
await updateAgentHandler(mockReq, mockRes);
@@ -1636,6 +1769,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.stateful_code_sessions).toBe(false);
expect(agentInDb.code_environment_id).toBe('removed-vm');
+ expect(agentInDb.code_workspace_id).toBe('project-a');
});
test('restores the deployment-default code environment', async () => {
@@ -1644,6 +1778,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
{
stateful_code_sessions: true,
code_environment_id: 'attached-vm',
+ code_workspace_id: 'project-a',
},
);
mockReq.user.id = existingAgentAuthorId.toString();
@@ -1673,6 +1808,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(mockRes.status).not.toHaveBeenCalledWith(400);
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.code_environment_id).toBeUndefined();
+ expect(agentInDb.code_workspace_id).toBe('');
});
test('clears a configured Git identity', async () => {
@@ -2867,6 +3003,67 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(mockRes.json.mock.calls[0][0].agent.tool_options).toEqual({});
});
+ test('duplicateAgentHandler preserves a disabled stale workspace binding', async () => {
+ const sourceAgent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Disabled BYOM Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: mockReq.user.id,
+ stateful_code_sessions: false,
+ code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
+ });
+ jest.spyOn(db, 'getActions').mockResolvedValueOnce([]);
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: { environments: [] },
+ },
+ },
+ };
+ mockReq.params.id = sourceAgent.id;
+
+ await duplicateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(201);
+ expect(mockRes.json.mock.calls[0][0].agent).toEqual(
+ expect.objectContaining({
+ stateful_code_sessions: false,
+ code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
+ }),
+ );
+ });
+
+ test('duplicateAgentHandler rejects an active stale workspace binding', async () => {
+ const sourceAgent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Active BYOM Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: mockReq.user.id,
+ stateful_code_sessions: true,
+ code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
+ });
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: { environments: [] },
+ },
+ },
+ };
+ mockReq.params.id = sourceAgent.id;
+
+ await duplicateAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Code workspace defaults require an explicit attached code environment',
+ });
+ });
+
test('revertAgentVersionHandler removes restored programmatic options without Code Interpreter', async () => {
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
@@ -2895,6 +3092,83 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(mockRes.json.mock.calls[0][0].tool_options).toEqual({});
});
+ test('revertAgentVersionHandler restores a disabled stale workspace binding', async () => {
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Current Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: mockReq.user.id,
+ versions: [
+ {
+ name: 'Disabled Historical BYOM Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ stateful_code_sessions: false,
+ code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
+ },
+ ],
+ });
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: { environments: [] },
+ },
+ },
+ };
+ mockReq.params.id = agent.id;
+ mockReq.body = { version_index: 0 };
+
+ await revertAgentVersionHandler(mockReq, mockRes);
+
+ expect(mockRes.status).not.toHaveBeenCalledWith(400);
+ const persisted = await Agent.findOne({ id: agent.id }).lean();
+ expect(persisted).toEqual(
+ expect.objectContaining({
+ stateful_code_sessions: false,
+ code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
+ }),
+ );
+ });
+
+ test('revertAgentVersionHandler rejects a stale binding that inherits active sessions', async () => {
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Current Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: mockReq.user.id,
+ stateful_code_sessions: true,
+ versions: [
+ {
+ name: 'Historical BYOM Agent',
+ provider: 'openai',
+ model: 'gpt-4',
+ code_environment_id: 'removed-vm',
+ code_workspace_id: 'project-a',
+ },
+ ],
+ });
+ mockReq.config = {
+ endpoints: {
+ agents: {
+ statefulCodeSessions: { environments: [] },
+ },
+ },
+ };
+ mockReq.params.id = agent.id;
+ mockReq.body = { version_index: 0 };
+
+ await revertAgentVersionHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(400);
+ expect(mockRes.json).toHaveBeenCalledWith({
+ error: 'Code workspace defaults require an explicit attached code environment',
+ });
+ });
+
test('revertAgentVersionHandler does not update unchanged tool options', async () => {
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
@@ -3332,16 +3606,20 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(Object.keys(agent).sort()).toEqual(
[
'_id',
+ 'agent_ids',
'author',
'avatar',
'category',
'conversation_starters',
'description',
+ 'edges',
'id',
'isEditable',
'is_promoted',
'name',
+ 'subagents',
'support_contact',
+ 'tools',
'updatedAt',
].sort(),
);
@@ -3353,6 +3631,9 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
author: userA.toString(),
category: 'general',
is_promoted: true,
+ tools: ['execute_code'],
+ edges: [{ from: agentA1.id, to: agentA2.id }],
+ subagents: { enabled: true, agent_ids: [agentA2.id] },
}),
);
});
diff --git a/api/server/controllers/assistants/chat.contentFilter.spec.js b/api/server/controllers/assistants/chat.contentFilter.spec.js
index 52d569a6e8c..9b3e6301024 100644
--- a/api/server/controllers/assistants/chat.contentFilter.spec.js
+++ b/api/server/controllers/assistants/chat.contentFilter.spec.js
@@ -11,6 +11,7 @@ const mockRetrieveAssistant = jest.fn();
const mockListThreadMessages = jest.fn();
const mockGetConvo = jest.fn();
const mockGetFiles = jest.fn();
+const mockEncodeAndFormat = jest.fn();
const mockGetOpenAIClient = jest.fn().mockResolvedValue({
openai: {
beta: {
@@ -90,7 +91,7 @@ jest.mock('~/app/clients/prompts', () => ({
}));
jest.mock('~/server/services/Files/images/encode', () => ({
- encodeAndFormat: jest.fn(),
+ encodeAndFormat: (...args) => mockEncodeAndFormat(...args),
}));
jest.mock('~/server/services/Runs', () => ({
@@ -135,6 +136,8 @@ jest.mock('./helpers', () => ({
const chatV1 = require('./chatV1');
const chatV2 = require('./chatV2');
+const { logger } = require('@librechat/data-schemas');
+const { ImageVisionTool } = require('librechat-data-provider');
describe.each([
['v1', chatV1],
@@ -157,6 +160,7 @@ describe.each([
});
mockGetFiles.mockReset().mockResolvedValue([]);
mockGetConvo.mockReset().mockResolvedValue(null);
+ mockEncodeAndFormat.mockReset().mockResolvedValue({ files: [], image_urls: [] });
mockInitThread.mockReset();
closeHandler = undefined;
req = {
@@ -382,6 +386,41 @@ describe.each([
});
if (_version === 'v1') {
+ describe('V1 vision attachment failures', () => {
+ it('does not log a signed storage URL from an image encoding error', async () => {
+ const signedUrl =
+ 'https://minio.example.com/bucket/image.png?X-Amz-Credential=secret&X-Amz-Signature=signed';
+ const failure = Object.assign(new Error(`NoSuchKey for ${signedUrl}`), {
+ statusCode: 404,
+ });
+ mockRetrieveAssistant.mockResolvedValueOnce({
+ id: 'asst-1',
+ instructions: 'Safe assistant',
+ tools: [{ type: 'function', function: { name: ImageVisionTool.function.name } }],
+ });
+ req.body.endpointOption.attachments = Promise.resolve([
+ {
+ source: 's3',
+ filepath: signedUrl,
+ storageKey: 'images/user/image.png',
+ },
+ ]);
+ mockEncodeAndFormat.mockRejectedValueOnce(failure);
+
+ await chatV1(req, res);
+
+ expect(mockEncodeAndFormat).toHaveBeenCalled();
+ const [message, ...metadata] = logger.error.mock.calls.find((call) =>
+ String(call[0]).startsWith('[/assistants/chat/]'),
+ );
+ expect(metadata).toEqual([]);
+ expect(message).toContain('NoSuchKey for https://minio.example.com/[redacted]');
+ expect(message).not.toContain('X-Amz-Signature');
+ expect(JSON.stringify(logger.error.mock.calls)).not.toContain(signedUrl);
+ expect(JSON.stringify(mockSendResponse.mock.calls)).not.toContain(signedUrl);
+ });
+ });
+
describe('V1 final conversation-file preflight', () => {
beforeEach(() => {
req.config.filters = {
diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js
index 5b33daa7950..5db12b87007 100644
--- a/api/server/controllers/assistants/chatV1.js
+++ b/api/server/controllers/assistants/chatV1.js
@@ -6,6 +6,7 @@ const {
countTokens,
checkBalance,
getBalanceConfig,
+ getSafeErrorText,
getModelMaxTokens,
getTransactionsConfig,
ATTACHMENT_ONLY_TEXT,
@@ -164,7 +165,7 @@ const chatV1 = async (req, res) => {
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(req, res, messageData, error.message);
} else {
- logger.error('[/assistants/chat/]', error);
+ logger.error(`[/assistants/chat/] ${getSafeErrorText(error)}`);
}
if (!openai || !thread_id || !run_id) {
diff --git a/api/server/index.js b/api/server/index.js
index 6fc63714fee..73ce8e45d79 100644
--- a/api/server/index.js
+++ b/api/server/index.js
@@ -403,6 +403,7 @@ const startServer = async () => {
app.use('/api/search', routes.search);
app.use('/api/messages', routes.messages);
app.use('/api/convos', routes.convos);
+ app.use('/api/traces', routes.traces);
app.use('/api/presets', routes.presets);
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js
index b4e9270f615..0fdb419324e 100644
--- a/api/server/routes/__test-utils__/convos-route-mocks.js
+++ b/api/server/routes/__test-utils__/convos-route-mocks.js
@@ -90,6 +90,21 @@ module.exports = {
}
return Math.min(Math.max(limit, 1), max);
}),
+ /** Mirrors the real whitelist and helpers so the route's sort normalization is exercised. */
+ CONVERSATION_SORT_FIELDS: {
+ title: true,
+ createdAt: true,
+ updatedAt: true,
+ archivedAt: true,
+ },
+ normalizeSortField: jest.fn((value, { fields, fallback }) => {
+ const raw = Array.isArray(value) ? value[0] : value;
+ return typeof raw === 'string' && fields[raw] === true ? raw : fallback;
+ }),
+ normalizeSortDirection: jest.fn((value, { fallback = 'desc' } = {}) => {
+ const raw = Array.isArray(value) ? value[0] : value;
+ return raw === 'asc' || raw === 'desc' ? raw : fallback;
+ }),
resolveImportMaxFileSize: jest.fn(() => 262144000),
createAxiosInstance: jest.fn(() => ({
get: jest.fn(),
diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
index 529879ce9ae..0ef61228421 100644
--- a/api/server/routes/__tests__/config.spec.js
+++ b/api/server/routes/__tests__/config.spec.js
@@ -116,6 +116,7 @@ afterEach(() => {
delete process.env.LANGFUSE_TRACING_ENABLED;
delete process.env.LANGFUSE_SAMPLE_RATE;
delete process.env.TENANT_ISOLATION_STRICT;
+ delete process.env.CODE_ENVIRONMENT_DECISION_VERSION;
});
describe('GET /api/config', () => {
@@ -350,8 +351,32 @@ describe('GET /api/config', () => {
expect(response.body.modelSpecs).toEqual({ list: [{ name: 'test-spec' }] });
expect(response.body.balance).toEqual({ enabled: true, startBalance: 10000 });
expect(response.body.webSearch).toEqual({ searchProvider: 'tavily' });
+ expect(response.body.codeEnvironmentDecisionVersion).toBeUndefined();
});
+ it('advertises code environment decisions only after deployment-wide activation', async () => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1';
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.codeEnvironmentDecisionVersion).toBe(1);
+ });
+
+ it.each(['0', '2', '1.0', 'true'])(
+ 'does not advertise unsupported code environment decision version %s',
+ async (version) => {
+ mockGetAppConfig.mockResolvedValue(baseAppConfig);
+ process.env.CODE_ENVIRONMENT_DECISION_VERSION = version;
+ const app = createApp(mockUser);
+
+ const response = await request(app).get('/api/config');
+
+ expect(response.body.codeEnvironmentDecisionVersion).toBeUndefined();
+ },
+ );
+
it('should strip private prompt fields from model spec presets', async () => {
mockGetAppConfig.mockResolvedValue({
...baseAppConfig,
diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js
index 535e5cd4407..b03940381b9 100644
--- a/api/server/routes/__tests__/convos.spec.js
+++ b/api/server/routes/__tests__/convos.spec.js
@@ -1872,6 +1872,40 @@ describe('Convos Routes', () => {
});
});
+ describe('GET / sort normalization', () => {
+ const { getConvosByCursor } = require('~/models');
+
+ beforeEach(() => {
+ getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null });
+ });
+
+ it('forwards a whitelisted sort field and direction', async () => {
+ const response = await request(app)
+ .get('/api/convos')
+ .query({ sortBy: 'title', sortDirection: 'asc' });
+
+ expect(response.status).toBe(200);
+ expect(getConvosByCursor).toHaveBeenCalledWith(
+ 'test-user-123',
+ expect.objectContaining({ sortBy: 'title', sortDirection: 'asc' }),
+ );
+ });
+
+ /** An unknown field reaches `getConvosByCursor`, which throws on it, so forwarding
+ * a stale or hand-edited value would answer 500 instead of listing anything. */
+ it('falls back to the default ordering for values it does not recognize', async () => {
+ const response = await request(app)
+ .get('/api/convos')
+ .query({ sortBy: 'DROP TABLE', sortDirection: 'sideways' });
+
+ expect(response.status).toBe(200);
+ expect(getConvosByCursor).toHaveBeenCalledWith(
+ 'test-user-123',
+ expect.objectContaining({ sortBy: 'updatedAt', sortDirection: 'desc' }),
+ );
+ });
+ });
+
describe('POST /archive', () => {
it('should archive a conversation successfully', async () => {
const mockConversationId = 'conv-123';
diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js
index 2076c2a4c33..46d53a3ce06 100644
--- a/api/server/routes/__tests__/messages-feedback.spec.js
+++ b/api/server/routes/__tests__/messages-feedback.spec.js
@@ -135,6 +135,32 @@ describe('PUT /:conversationId/:messageId/feedback', () => {
);
});
+ it('scores the trace of the run a failed turn stands for', async () => {
+ updateMessage.mockImplementationOnce((userId, { messageId, feedback }) =>
+ Promise.resolve({
+ messageId,
+ conversationId: 'conversation-1',
+ endpoint: 'agents',
+ langfuseSampled: true,
+ langfuseDestinationIds: ['destination-1'],
+ langfuseRunId: 'run-1',
+ feedback,
+ }),
+ );
+
+ const response = await request(app)
+ .put('/api/messages/conversation-1/user-1_/feedback')
+ .send({ feedback: { rating: 'thumbsDown', tag: 'other' } });
+
+ expect(response.status).toBe(200);
+ expect(sendFeedbackScore).toHaveBeenCalledWith(
+ expect.objectContaining({
+ traceId: 'trace-run-1',
+ metadata: expect.objectContaining({ messageId: 'user-1_' }),
+ }),
+ );
+ });
+
it.each([
['an object tag', { rating: 'thumbsDown', tag: { key: 'inaccurate' } }],
['a tag for the opposite rating', { rating: 'thumbsUp', tag: 'inaccurate' }],
diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js
index d25357bd120..b72c0973f48 100644
--- a/api/server/routes/__tests__/messages-get.spec.js
+++ b/api/server/routes/__tests__/messages-get.spec.js
@@ -51,6 +51,9 @@ jest.mock('@librechat/api', () => {
};
return {
+ /** The real helper, without loading the rest of the package this suite mocks around. */
+ withoutTraceRefs: jest.requireActual('../../../../packages/api/src/langfuse/trace.ts')
+ .withoutTraceRefs,
createContentFilter: jest.fn(() => (req, res, next) => next()),
inspectContent,
extractChatContent,
@@ -528,6 +531,9 @@ describe('message route conversation ownership filters', () => {
encoding: 'claude',
fading: { v: 1, budgetTokens: 1, masked: true },
},
+ langfuseSampled: true,
+ langfuseDestinationIds: ['forged-destination'],
+ langfuseRunId: 'someone-elses-run',
});
expect(response.status).toBe(201);
@@ -546,6 +552,9 @@ describe('message route conversation ownership filters', () => {
expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('userSubmittedPaths');
expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('userSubmittedMessageFieldPaths');
expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('contextMeta');
+ expect(saveMessage.mock.calls[0][1].langfuseSampled).toBe(false);
+ expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('langfuseDestinationIds');
+ expect(saveMessage.mock.calls[0][1]).not.toHaveProperty('langfuseRunId');
expect(response.body.messageId).toBe(savedMessage.messageId);
expect(response.body).not.toHaveProperty('contextMeta');
expect(saveConvo).toHaveBeenCalledWith(
diff --git a/api/server/routes/__tests__/messages-subagent-thread.spec.js b/api/server/routes/__tests__/messages-subagent-thread.spec.js
index bb71adf098b..3c7ee69e759 100644
--- a/api/server/routes/__tests__/messages-subagent-thread.spec.js
+++ b/api/server/routes/__tests__/messages-subagent-thread.spec.js
@@ -6,6 +6,7 @@ const mockIsSubagentThreadWriteBlocked = jest.fn();
jest.mock('@librechat/agents', () => ({ sleep: jest.fn() }));
jest.mock('@librechat/api', () => ({
+ withoutTraceRefs: jest.fn((message) => message),
createContentFilter: jest.fn(() => (_req, _res, next) => next()),
unescapeLaTeX: jest.fn((value) => value),
countTokens: jest.fn().mockResolvedValue(1),
diff --git a/api/server/routes/__tests__/traces.spec.js b/api/server/routes/__tests__/traces.spec.js
new file mode 100644
index 00000000000..1e202008448
--- /dev/null
+++ b/api/server/routes/__tests__/traces.spec.js
@@ -0,0 +1,173 @@
+const express = require('express');
+const request = require('supertest');
+const { createHash } = require('crypto');
+
+const mockGetConvoOwnership = jest.fn();
+const mockGetConversationTraceRefs = jest.fn();
+const mockHasSampledTraceMessage = jest.fn();
+let mockUser;
+let mockTraceViewer;
+
+jest.mock('~/models', () => ({
+ getConvoOwnership: (...args) => mockGetConvoOwnership(...args),
+ getConversationTraceRefs: (...args) => mockGetConversationTraceRefs(...args),
+ hasSampledTraceMessage: (...args) => mockHasSampledTraceMessage(...args),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => {
+ if (!mockUser) {
+ return res.status(401).json({ message: 'Unauthorized' });
+ }
+ req.user = mockUser;
+ next();
+ },
+}));
+
+jest.mock('~/server/middleware/config/app', () => (req, _res, next) => {
+ req.config = { interfaceConfig: { traceViewer: mockTraceViewer } };
+ next();
+});
+
+const traceIdFor = (seed) => createHash('sha256').update(seed, 'utf8').digest('hex').slice(0, 32);
+
+const langfuseEnv = {
+ LANGFUSE_PUBLIC_KEY: 'pk-route',
+ LANGFUSE_SECRET_KEY: 'sk-route',
+ LANGFUSE_PROJECT_ID: 'route-project',
+ LANGFUSE_BASE_URL: 'https://langfuse.route.test',
+};
+
+describe('trace routes', () => {
+ let app;
+ let fetchSpy;
+
+ beforeAll(() => {
+ Object.assign(process.env, langfuseEnv);
+ const tracesRouter = require('../traces');
+ app = express();
+ app.use('/api/traces', tracesRouter);
+ });
+
+ afterAll(() => {
+ for (const key of Object.keys(langfuseEnv)) {
+ delete process.env[key];
+ }
+ });
+
+ beforeEach(() => {
+ mockUser = { id: 'owner', role: 'USER' };
+ mockTraceViewer = { enabled: true };
+ mockGetConvoOwnership.mockResolvedValue({ user: 'owner' });
+ mockGetConversationTraceRefs.mockResolvedValue({
+ firstMessageAt: new Date('2026-09-12T11:00:00.000Z'),
+ sampledMessages: [{ messageId: 'response-1' }],
+ });
+ mockHasSampledTraceMessage.mockResolvedValue(true);
+ fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: [
+ {
+ id: 'obs-root',
+ traceId: traceIdFor('response-1'),
+ startTime: '2026-09-12T11:30:00.000Z',
+ endTime: '2026-09-12T11:30:02.000Z',
+ parentObservationId: null,
+ type: 'AGENT',
+ name: 'AgentGraph',
+ level: 'DEFAULT',
+ },
+ {
+ id: 'obs-foreign',
+ traceId: traceIdFor('other-response'),
+ startTime: '2026-09-12T11:31:00.000Z',
+ endTime: '2026-09-12T11:31:02.000Z',
+ parentObservationId: null,
+ type: 'AGENT',
+ name: 'AgentGraph',
+ },
+ ],
+ meta: {},
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ );
+ });
+
+ afterEach(() => {
+ fetchSpy.mockRestore();
+ });
+
+ it('requires authentication', async () => {
+ mockUser = undefined;
+
+ const response = await request(app).get('/api/traces/convo-1/records');
+
+ expect(response.status).toBe(401);
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it("reads the owner's sampled traces from Langfuse without exposing other traces", async () => {
+ const availability = await request(app).get('/api/traces/convo-1/availability');
+ const response = await request(app).get('/api/traces/convo-1/records');
+
+ expect(availability.body).toEqual({ available: true });
+ expect(response.status).toBe(200);
+ expect(response.body.records.map(({ id }) => id)).toEqual(['obs-root']);
+ expect(response.body.records[0]).toMatchObject({ messageId: 'response-1', kind: 'agent' });
+ expect(mockGetConvoOwnership).toHaveBeenCalledWith('owner', 'convo-1', null);
+ expect(mockHasSampledTraceMessage).toHaveBeenCalledWith({
+ user: 'owner',
+ conversationId: 'convo-1',
+ tenantId: undefined,
+ destinationIds: [
+ createHash('sha256').update('https://langfuse.route.test\nroute-project').digest('hex'),
+ ],
+ });
+ expect(mockGetConversationTraceRefs).toHaveBeenCalledWith({
+ user: 'owner',
+ conversationId: 'convo-1',
+ tenantId: undefined,
+ limit: 51,
+ });
+ const [url, init] = fetchSpy.mock.calls[0];
+ expect(new URL(url).origin).toBe('https://langfuse.route.test');
+ expect(JSON.parse(new URL(url).searchParams.get('filter'))).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ column: 'sessionId', operator: '=', value: 'convo-1' }),
+ expect.objectContaining({ column: 'traceId', operator: 'any of' }),
+ expect.objectContaining({ column: 'userId', operator: 'any of', value: ['owner'] }),
+ ]),
+ );
+ expect(new Headers(init.headers).get('Authorization')).toBe(
+ `Basic ${Buffer.from('pk-route:sk-route').toString('base64')}`,
+ );
+ expect(JSON.stringify(response.body)).not.toContain('sk-route');
+ });
+
+ it("refuses another user's conversation before reading Langfuse", async () => {
+ mockGetConvoOwnership.mockResolvedValue(null);
+
+ const availability = await request(app).get('/api/traces/convo-1/availability');
+ const response = await request(app).get(
+ '/api/traces/convo-1/records/obs-root?message=response-1',
+ );
+
+ expect(availability.body).toEqual({ available: false });
+ expect(response.status).toBe(404);
+ expect(mockGetConversationTraceRefs).not.toHaveBeenCalled();
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+
+ it('stays off until the deployment enables the viewer', async () => {
+ mockTraceViewer = undefined;
+
+ const response = await request(app).get('/api/traces/convo-1/records');
+
+ expect(response.status).toBe(404);
+ expect(response.body.errorCode).toBe('disabled');
+ expect(fetchSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index 4f584b40c50..99ab46ad4ec 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -12,6 +12,7 @@ const {
excludeHiddenModelSpecs,
isFileSnapshotEnabled,
getEndpointsDropParamsMap,
+ resolveCodeEnvironmentDecisionVersion,
} = require('@librechat/api');
const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
@@ -249,6 +250,9 @@ router.get('/', async function (req, res) {
}
const appConfig = await getAppConfig(getAppConfigOptionsFromUser(req.user));
+ const codeEnvironmentDecisionVersion = resolveCodeEnvironmentDecisionVersion(
+ process.env.CODE_ENVIRONMENT_DECISION_VERSION,
+ );
const endpointsDropParamsMap = getEndpointsDropParamsMap(appConfig?.endpoints);
@@ -309,6 +313,7 @@ router.get('/', async function (req, res) {
langfuseConnectionAccess,
insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS),
compactionEnabled: appConfig?.summarization?.enabled !== false,
+ ...(codeEnvironmentDecisionVersion != null ? { codeEnvironmentDecisionVersion } : {}),
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
fileUploadSseEnabled: isEnabled(process.env.FILE_UPLOAD_SSE_ENABLED),
diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js
index 04c9ec5d992..dc45fd6a3b9 100644
--- a/api/server/routes/convos.js
+++ b/api/server/routes/convos.js
@@ -5,6 +5,9 @@ const {
reportLocatorTraversalFailure,
isEnabled,
normalizeLimit,
+ normalizeSortDirection,
+ normalizeSortField,
+ CONVERSATION_SORT_FIELDS,
openCheckpointDeletion,
waitForGenerationPersistence,
createArchiveAllHandler,
@@ -153,8 +156,11 @@ router.get('/', async (req, res) => {
const pinned = isEnabled(req.query.pinned);
const search =
typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
- const sortBy = req.query.sortBy || 'updatedAt';
- const sortDirection = req.query.sortDirection || 'desc';
+ const sortBy = normalizeSortField(req.query.sortBy, {
+ fields: CONVERSATION_SORT_FIELDS,
+ fallback: 'updatedAt',
+ });
+ const sortDirection = normalizeSortDirection(req.query.sortDirection);
const projectId = Array.isArray(req.query.projectId)
? req.query.projectId[0]
: req.query.projectId;
diff --git a/api/server/routes/index.js b/api/server/routes/index.js
index c3b6c4bb8e7..7827559f112 100644
--- a/api/server/routes/index.js
+++ b/api/server/routes/index.js
@@ -28,6 +28,7 @@ const banner = require('./banner');
const search = require('./search');
const models = require('./models');
const convos = require('./convos');
+const traces = require('./traces');
const config = require('./config');
const agents = require('./agents');
const roles = require('./roles');
@@ -69,6 +70,7 @@ module.exports = {
banner,
agents,
convos,
+ traces,
search,
config,
models,
diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js
index 756f8ff8cb1..6ac391c18d4 100644
--- a/api/server/routes/messages.js
+++ b/api/server/routes/messages.js
@@ -26,6 +26,7 @@ const {
mergeUserSubmittedPaths,
mergeUserSubmittedMessageFieldPaths,
isContentFilterError,
+ withoutTraceRefs,
} = require('@librechat/api');
const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore');
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
@@ -522,7 +523,8 @@ router.post('/:conversationId', storedMessageMutationMiddleware, async (req, res
if (await rejectSubagentThreadWrite(req, res, req.params.conversationId)) {
return;
}
- const message = { ...req.body, conversationId: req.params.conversationId };
+ /** Trace sampling fields are ownership claims only the server writes. */
+ const message = withoutTraceRefs({ ...req.body, conversationId: req.params.conversationId });
delete message.isUserSubmitted;
delete message.userSubmittedPaths;
delete message.userSubmittedMessageFieldPaths;
@@ -716,7 +718,7 @@ router.put(
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
sendFeedbackScore({
- traceId: traceIdForMessage(messageId),
+ traceId: traceIdForMessage(updatedMessage.langfuseRunId ?? messageId),
sampled: updatedMessage.langfuseSampled,
destinationIds: updatedMessage.langfuseDestinationIds,
feedback: updatedMessage.feedback,
diff --git a/api/server/routes/traces.js b/api/server/routes/traces.js
new file mode 100644
index 00000000000..b52869ed14f
--- /dev/null
+++ b/api/server/routes/traces.js
@@ -0,0 +1,33 @@
+const express = require('express');
+const {
+ createTraceHandlers,
+ limiterCache,
+ createTraceReadLimiter,
+ createLangfuseTraceReader,
+ resolveLangfuseReadDestinations,
+} = require('@librechat/api');
+const configMiddleware = require('~/server/middleware/config/app');
+const { requireJwtAuth } = require('~/server/middleware');
+const db = require('~/models');
+
+const router = express.Router();
+
+const handlers = createTraceHandlers({
+ reader: createLangfuseTraceReader({
+ getConversationTraceRefs: db.getConversationTraceRefs,
+ hasSampledTraceMessage: db.hasSampledTraceMessage,
+ resolveDestinations: resolveLangfuseReadDestinations,
+ fetch: (url, init) => fetch(url, init),
+ }),
+ getConvoOwnership: db.getConvoOwnership,
+});
+const traceReadLimiter = createTraceReadLimiter({
+ store: limiterCache('trace_viewer_user_limiter'),
+});
+
+router.use(requireJwtAuth, configMiddleware);
+router.get('/:conversationId/availability', handlers.availability);
+router.get('/:conversationId/records', traceReadLimiter, handlers.records);
+router.get('/:conversationId/records/:recordId', traceReadLimiter, handlers.record);
+
+module.exports = router;
diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js
index 911b7ebdc4d..24d26b895da 100644
--- a/api/server/services/AuthService.js
+++ b/api/server/services/AuthService.js
@@ -1005,8 +1005,8 @@ const resendVerificationEmail = async (req) => {
} catch (error) {
logger.error(`[resendVerificationEmail] Error resending verification email: ${error.message}`);
return {
- status: 500,
- message: 'Something went wrong.',
+ status: 200,
+ message: genericVerificationMessage,
};
}
};
diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js
index d6182e1334c..f9e239fef83 100644
--- a/api/server/services/AuthService.spec.js
+++ b/api/server/services/AuthService.spec.js
@@ -1227,6 +1227,26 @@ describe('resendVerificationEmail', () => {
}),
);
});
+
+ it('returns the generic response when delivery fails', async () => {
+ const user = {
+ _id: 'user-delivery-failure',
+ email: 'delivery-failure@example.com',
+ name: 'Delivery Failure',
+ };
+ findUser.mockResolvedValue(user);
+ sendEmail.mockRejectedValue(new Error('mail transport unavailable'));
+
+ const result = await resendVerificationEmail({ body: { email: user.email } });
+
+ expect(result).toEqual({
+ status: 200,
+ message: 'Please check your email to verify your email address.',
+ });
+ expect(logger.error).toHaveBeenCalledWith(
+ '[resendVerificationEmail] Error resending verification email: mail transport unavailable',
+ );
+ });
});
describe('CloudFront cookie integration', () => {
diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js
index dd5e231f023..efc92b41836 100644
--- a/api/server/services/Config/__tests__/getCachedTools.spec.js
+++ b/api/server/services/Config/__tests__/getCachedTools.spec.js
@@ -431,10 +431,25 @@ describe('MCP tool cache', () => {
);
});
+ it('reports the rotated generation so its caller can tell its own fence from a foreign one', async () => {
+ mockCache.set.mockResolvedValue(true);
+ mockCache.delete.mockResolvedValue(true);
+
+ const published = await invalidateCachedTools({ userId: 'user1', serverName: 'github' });
+
+ expect(published).toEqual(expect.any(String));
+ expect(mockCache.set).toHaveBeenNthCalledWith(
+ 2,
+ ToolCacheKeys.MCP_SERVER_GENERATION('user1', 'github'),
+ published,
+ expect.any(Number),
+ );
+ });
+
it('invalidates only the static global key for broad config changes', async () => {
mockCache.delete.mockResolvedValue(true);
- await invalidateCachedTools({ invalidateGlobal: true });
+ await expect(invalidateCachedTools({ invalidateGlobal: true })).resolves.toBeUndefined();
expect(mockCache.delete).toHaveBeenCalledTimes(1);
expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL);
diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js
index 8a9e10d92ca..1764359a219 100644
--- a/api/server/services/Endpoints/agents/addedConvo.js
+++ b/api/server/services/Endpoints/agents/addedConvo.js
@@ -216,6 +216,7 @@ const processAddedConvo = async ({
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
+ getRoleByName: db.getRoleByName,
},
);
diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js
index f5653ec8b8e..326a44b6b1b 100644
--- a/api/server/services/Endpoints/agents/initialize.js
+++ b/api/server/services/Endpoints/agents/initialize.js
@@ -3,6 +3,7 @@ const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents');
const {
resolveSender,
resolveRunConversation,
+ resolveConversationCodeEnvironmentDecision,
createConcurrencyLimiter,
loadSkillStates,
initializeAgent,
@@ -23,11 +24,22 @@ const {
getLazySubagentConfigId,
resolveCodeExecutionContext,
resolveCodeExecutionWorkspaceContext,
+ optsOutOfAttachedCodeEnvironment,
+ isImplicitStatefulCodeRouteAvailable,
createStatefulCodeEnvironmentPolicyError,
buildSubagentThreadTaskConfig,
backgroundCompletionWakeupsEnabled,
createLazyAgentHistoryResolver,
resolveToolRoleGrants,
+ createChatRunFileBindings,
+ createAxiosInstance,
+ getCodeApiAuthHeaders,
+ getCodeExecutionBaseUrl,
+ getAuthorizedRunFileSnapshot,
+ encodeAndFormatDocuments,
+ encodeAndFormatAudios,
+ encodeAndFormatVideos,
+ extractFileContext,
} = require('@librechat/api');
const {
ResourceType,
@@ -38,6 +50,7 @@ const {
AgentCapabilities,
normalizeServerName,
Tools,
+ VisionModes,
MAX_SUBAGENT_GRAPH_NODES,
MAX_SUBAGENT_RUN_CONFIGS,
isEphemeralAgentId,
@@ -86,6 +99,10 @@ const {
const { logViolation } = require('~/cache');
const db = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
+const { encodeAndFormat } = require('~/server/services/Files/images/encode');
+const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
+const { determineFileType } = require('~/server/utils');
const SUBAGENT_GRAPH_LOAD_CONCURRENCY = 4;
@@ -192,7 +209,7 @@ const initializeClient = async ({
/** The normal controller resolves this once for timestamp anchoring. Reuse
* that trusted document for child-thread execution policy; resume and direct
* callers fall back to the same owner-scoped lookup. */
- const runtimeRequestBody = requestBody ?? req.body;
+ let runtimeRequestBody = requestBody ?? req.body;
const conversationId = runtimeRequestBody?.conversationId;
const requestConversationPromise = resolveRunConversation({
request: req,
@@ -346,6 +363,7 @@ const initializeClient = async ({
* }>}
*/
const agentToolContexts = new Map();
+ let runFileBindings;
const resolveMcpServerName = (toolName, agentId) => {
if (typeof toolName !== 'string' || typeof agentId !== 'string') {
return undefined;
@@ -400,7 +418,7 @@ const initializeClient = async ({
if (trustedContext?.codeExecutionContext) {
callbackMetadata.codeExecutionContext = trustedContext.codeExecutionContext;
}
- return artifactToolEndCallback(data, callbackMetadata);
+ return runFileBindings.deliverToolEnd(artifactToolEndCallback, data, callbackMetadata);
};
/** @type {Map} */
const endpointTokenConfigByAgentId = new Map();
@@ -413,8 +431,15 @@ const initializeClient = async ({
runSignal: signal,
foregroundRunId,
ordinaryToolCancellation: ordinaryToolCancellationEnabled,
- loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection, runSignal) => {
- const ctx = agentToolContexts.get(agentId) ?? {};
+ loadTools: async (
+ toolNames,
+ agentId,
+ _configurable,
+ callerCapabilityProjection,
+ runSignal,
+ executionContext,
+ ) => {
+ const ctx = runFileBindings.getContext(agentId, executionContext) ?? {};
logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`);
logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`);
@@ -427,6 +452,10 @@ const initializeClient = async ({
requestBody: runtimeRequestBody,
toolNames,
agent: ctx.agent,
+ runFileCodeExecutionContext: runFileBindings.getCodeExecutionContext(
+ agentId,
+ executionContext,
+ ),
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
@@ -558,6 +587,25 @@ const initializeClient = async ({
]);
/** Preserve the owner-scoped fallback for loaders that share this request. */
req.resolvedConversation = requestConversation;
+ const codeEnvironmentDecision = resolveConversationCodeEnvironmentDecision({
+ conversationId,
+ requestedMode: runtimeRequestBody?.codeEnvironmentMode,
+ requestedSelections: runtimeRequestBody?.codeWorkspaces,
+ conversation: requestConversation,
+ });
+ /** Trusted, normalized pair used by every persistence path, including init failures. */
+ req._codeEnvironmentDecision = codeEnvironmentDecision;
+ runtimeRequestBody = {
+ ...runtimeRequestBody,
+ codeEnvironmentMode: codeEnvironmentDecision.mode,
+ codeWorkspaces: codeEnvironmentDecision.codeWorkspaces,
+ };
+ req.body.codeEnvironmentMode = codeEnvironmentDecision.mode;
+ if (codeEnvironmentDecision.codeWorkspaces == null) {
+ delete req.body.codeWorkspaces;
+ } else {
+ req.body.codeWorkspaces = codeEnvironmentDecision.codeWorkspaces;
+ }
delete endpointOption.agent;
/** The deployment switch AND the role grant. `initializeAgent` rebuilds
@@ -686,6 +734,7 @@ const initializeClient = async ({
checkSessionsAlive,
loadCodeApiKey,
updateFile: db.updateFile,
+ getRoleByName: db.getRoleByName,
},
);
@@ -776,6 +825,7 @@ const initializeClient = async ({
checkSessionsAlive,
loadCodeApiKey,
updateFile: db.updateFile,
+ getRoleByName: db.getRoleByName,
},
// The callback fires during BFS, before the helper prunes agents
// whose edges end up filtered. Don't populate `agentConfigs` here โ
@@ -1006,8 +1056,21 @@ const initializeClient = async ({
});
const toLazySubagentMetadata = async (agent) => {
+ const configuredCodeEnvironments =
+ appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments;
+ const attachedEnvironmentOptOut = optsOutOfAttachedCodeEnvironment(
+ agent,
+ runtimeRequestBody,
+ configuredCodeEnvironments,
+ isImplicitStatefulCodeRouteAvailable(
+ process.env.CODE_ENVIRONMENT_DECISION_VERSION,
+ process.env.LIBRECHAT_CODE_BASEURL_STATEFUL,
+ ),
+ );
const lazyCodeEnvAvailable =
- codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true;
+ codeEnvAvailable === true &&
+ agent.tools?.includes(Tools.execute_code) === true &&
+ !attachedEnvironmentOptOut;
const statefulCodeSessions =
statefulSessionsAvailable === true &&
lazyCodeEnvAvailable &&
@@ -1020,23 +1083,17 @@ const initializeClient = async ({
) {
throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment);
}
- const configuredCodeEnvironments =
- appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments;
- const hasConfiguredCodeEnvironment =
- agent.code_environment_id != null ||
- configuredCodeEnvironments?.some((environment) => environment.default === true) === true;
- const baseCodeExecutionContext =
- lazyCodeEnvAvailable && (!statefulCodeSessions || hasConfiguredCodeEnvironment)
- ? resolveCodeExecutionContext({
- statefulSessions: statefulCodeSessions,
- environment: statefulCodeEnvironment,
- environmentId: agent.code_environment_id,
- environments: configuredCodeEnvironments,
- userId,
- agentId: agent.id,
- conversationId,
- })
- : undefined;
+ const baseCodeExecutionContext = lazyCodeEnvAvailable
+ ? resolveCodeExecutionContext({
+ statefulSessions: statefulCodeSessions,
+ environment: statefulCodeEnvironment,
+ environmentId: agent.code_environment_id,
+ environments: configuredCodeEnvironments,
+ userId,
+ agentId: agent.id,
+ conversationId,
+ })
+ : undefined;
const codeExecutionContext = baseCodeExecutionContext
? await resolveCodeExecutionWorkspaceContext({
context: baseCodeExecutionContext,
@@ -1193,6 +1250,11 @@ const initializeClient = async ({
agent,
loadTools: createToolLoader(req, res, context.signal, streamId, true, jobCreatedAt),
requestFiles,
+ authorizedRunFiles: getAuthorizedRunFileSnapshot({
+ policy: appConfig.endpoints?.agents?.fileSharing,
+ agent: primaryConfig,
+ files: primaryConfig.currentRequestAttachments,
+ }),
conversationId,
parentMessageId,
requestBody: runtimeRequestBody,
@@ -1237,6 +1299,7 @@ const initializeClient = async ({
checkSessionsAlive,
loadCodeApiKey,
updateFile: db.updateFile,
+ getRoleByName: db.getRoleByName,
},
),
context.signal,
@@ -1648,6 +1711,54 @@ const initializeClient = async ({
}
: null;
+ runFileBindings = createChatRunFileBindings({
+ req,
+ contexts: agentToolContexts,
+ createdAt: jobCreatedAt,
+ requestFiles,
+ audit: (event) => logger.info('[agents:run-files]', event),
+ getInputs: () => primaryConfig.currentRequestAttachments,
+ loadFiles: (fileIds) => db.getRunFileCandidates(fileIds, req.user.tenantId),
+ filterFiles: filterFilesByAgentAccess,
+ listPublications: db.listRunArtifacts,
+ provisioning: {
+ provisionToCodeEnv,
+ provisionToVectorDB,
+ updateFile: db.updateFile,
+ updateCodeEnvRef: db.updateFileCodeEnvRef,
+ addEmbeddedEntity: db.addFileEmbeddedEntity,
+ },
+ fileMethods: {
+ claimRunArtifactFile: db.claimRunArtifactFile,
+ publishRunArtifactFile: db.publishRunArtifactFile,
+ findRunArtifactFile: db.findRunArtifactFile,
+ },
+ processCodeOutput,
+ snapshotAdapter: {
+ request: createAxiosInstance(),
+ getAuthHeaders: getCodeApiAuthHeaders,
+ getBaseURL: getCodeExecutionBaseUrl,
+ determineFileType,
+ },
+ finalize: runPreviewFinalize,
+ getStrategyFunctions,
+ artifactPromises,
+ emitAttachment: createAttachmentEmitter({ res, streamId, jobCreatedAt }),
+ encoder: {
+ getAgent: (agentId) => agentToolContexts.get(agentId)?.fileEncodingAgent,
+ encodeImages: (request, files, params) =>
+ encodeAndFormat(request, files, params, VisionModes.agents),
+ encodeDocuments: encodeAndFormatDocuments,
+ encodeAudios: encodeAndFormatAudios,
+ encodeVideos: encodeAndFormatVideos,
+ extractText: extractFileContext,
+ },
+ });
+ toolExecuteOptions.runFiles = runFileBindings.session;
+ toolExecuteOptions.provisionFiles = runFileBindings.wrapProvision(
+ toolExecuteOptions.provisionFiles,
+ );
+
const eventHandlers = getDefaultHandlers({
res,
contentParts,
@@ -1697,6 +1808,7 @@ const initializeClient = async ({
endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents,
subagentAggregatorsByToolCallId,
subagentTasks,
+ runFiles: runFileBindings.session,
/** Resolved endpoint token/pricing config so spending and cost reflect
* configured rates for custom-endpoint agents instead of defaults. */
endpointTokenConfig: primaryConfig.endpointTokenConfig,
diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js
index 836268ab199..52241abda3e 100644
--- a/api/server/services/Endpoints/agents/initialize.spec.js
+++ b/api/server/services/Endpoints/agents/initialize.spec.js
@@ -896,12 +896,17 @@ describe('initializeClient โ subagent loading', () => {
requestBody,
});
- expect(mockInitializeAgent.mock.calls[0][0].requestBody).toBe(requestBody);
- expect(agentClientArgs.mcpRequestBody).toBe(requestBody);
+ const normalizedRequestBody = mockInitializeAgent.mock.calls[0][0].requestBody;
+ expect(normalizedRequestBody).toEqual({
+ ...requestBody,
+ codeEnvironmentMode: 'without_attached',
+ codeWorkspaces: undefined,
+ });
+ expect(agentClientArgs.mcpRequestBody).toBe(normalizedRequestBody);
await capturedToolExecuteOptions.loadTools([], PRIMARY_ID);
expect(mockLoadToolsForExecution).toHaveBeenCalledWith(
- expect.objectContaining({ requestBody }),
+ expect.objectContaining({ requestBody: normalizedRequestBody }),
);
});
@@ -1460,20 +1465,74 @@ describe('initializeClient โ subagent loading', () => {
const req = makeSubagentReq();
req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions');
req.config.endpoints.agents.statefulCodeSessions = { allowedEnvironments: ['user'] };
+ process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1';
+ process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful-code.example.com/v1/';
- await expect(
- initializeClient({
+ try {
+ await expect(
+ initializeClient({
+ req,
+ res: {},
+ signal: new AbortController().signal,
+ endpointOption: makeEndpointOption(),
+ }),
+ ).rejects.toMatchObject({
+ code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED,
+ });
+
+ expect(agentClientArgs).toBeUndefined();
+ expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
+ } finally {
+ delete process.env.CODE_ENVIRONMENT_DECISION_VERSION;
+ delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
+ }
+ });
+
+ it('binds a versioned implicit stateful route into lazy subagent metadata', async () => {
+ const subAgent = await createAgent({
+ id: SUBAGENT_ID,
+ name: 'Implicit Stateful Subagent',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: new mongoose.Types.ObjectId(),
+ tools: ['execute_code'],
+ stateful_code_sessions: true,
+ stateful_code_environment: 'user',
+ });
+ await grantView(subAgent);
+ mockInitializeAgent.mockResolvedValue(
+ makePrimaryConfig({
+ subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
+ }),
+ );
+ const req = makeSubagentReq();
+ req.body.codeEnvironmentMode = 'without_attached';
+ req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions');
+ req.config.endpoints.agents.statefulCodeSessions = { allowedEnvironments: ['user'] };
+ process.env.CODE_ENVIRONMENT_DECISION_VERSION = '1';
+ process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful-code.example.com/v1/';
+
+ try {
+ await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
- }),
- ).rejects.toMatchObject({
- code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED,
- });
+ });
- expect(agentClientArgs).toBeUndefined();
- expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
+ expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual(
+ expect.objectContaining({
+ codeExecutionContext: expect.objectContaining({
+ baseUrl: 'https://stateful-code.example.com/v1',
+ executionProfile: 'stateful',
+ statefulSessions: true,
+ }),
+ }),
+ );
+ } finally {
+ delete process.env.CODE_ENVIRONMENT_DECISION_VERSION;
+ delete process.env.LIBRECHAT_CODE_BASEURL_STATEFUL;
+ }
});
it.each([
@@ -1582,7 +1641,8 @@ describe('initializeClient โ subagent loading', () => {
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
- if (!registered) {
+ const defaultsWithoutAttached = source === 'resolved-null' || source === 'other-owner';
+ if (!registered && !defaultsWithoutAttached) {
await expect(initialization).rejects.toMatchObject({
code: ErrorTypes.CODE_WORKSPACE_UNAVAILABLE,
});
@@ -1590,6 +1650,13 @@ describe('initializeClient โ subagent loading', () => {
return;
}
await initialization;
+ if (defaultsWithoutAttached) {
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(agentClientArgs.mcpRequestBody).toEqual(
+ expect.objectContaining({ codeEnvironmentMode: 'without_attached' }),
+ );
+ return;
+ }
if (source === 'fallback' || source.startsWith('override')) {
mockInitializeAgent.mockImplementationOnce(async (params) => {
expect(params.req.resolvedConversation.codeWorkspaces).toEqual([
@@ -2019,6 +2086,19 @@ describe('initializeClient โ subagent loading', () => {
'tool_intents',
'stateful_code_sessions',
);
+ req.config.endpoints.agents.statefulCodeSessions = {
+ allowedEnvironments: ['user'],
+ environments: [
+ {
+ id: 'managed-code',
+ name: 'Managed code',
+ type: 'managed',
+ baseURL: 'https://stateful-code.example.com/v1',
+ default: true,
+ },
+ ],
+ };
+ mockGetAppConfig.mockResolvedValue(req.config);
const { userMCPAuthMap } = await initializeClient({
req,
res: {},
diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js
index 2558ba021c1..c8aa0f2b303 100644
--- a/api/server/services/Endpoints/agents/skillDeps.js
+++ b/api/server/services/Endpoints/agents/skillDeps.js
@@ -289,6 +289,13 @@ function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) {
function buildAgentToolContext({ agent, config }) {
return {
agent,
+ fileEncodingAgent: {
+ provider: config.provider,
+ endpoint: config.endpoint,
+ model_parameters: config.model_parameters,
+ imageDetail: config.imageDetail,
+ agentContextAttachments: config.agentContextAttachments,
+ },
/** Per-agent resolved endpoint token/pricing config. Retained here because
* `agentToolContexts` is the one map that holds every agent โ including
* pure subagents pruned from `agentConfigs` โ so usage can be priced with
diff --git a/api/server/services/Files/Azure/crud.js b/api/server/services/Files/Azure/crud.js
index 5944f40ab7d..1fea4db7d5c 100644
--- a/api/server/services/Files/Azure/crud.js
+++ b/api/server/services/Files/Azure/crud.js
@@ -6,6 +6,7 @@ const { logger } = require('@librechat/data-schemas');
const {
deleteRagFile,
assertRemoteFileURL,
+ getSafeErrorMetadata,
getAzureContainerClient,
getRemoteFileFetchMaxBytes,
getRemoteFileFetchTimeoutMs,
@@ -287,7 +288,7 @@ async function getAzureFileStream(_req, fileURL, { signal } = {}) {
}
return response.readableStreamBody;
} catch (error) {
- logger.error('[getAzureFileStream] Error getting blob stream:', error);
+ logger.error('[getAzureFileStream] Error getting blob stream:', getSafeErrorMetadata(error));
throw error;
}
}
diff --git a/api/server/services/Files/Azure/crud.spec.js b/api/server/services/Files/Azure/crud.spec.js
index c7e87b1d9b3..bafbb98430e 100644
--- a/api/server/services/Files/Azure/crud.spec.js
+++ b/api/server/services/Files/Azure/crud.spec.js
@@ -4,6 +4,7 @@ const mockGetAzureContainerClient = jest.fn(async () => ({
url: 'https://account.blob.core.windows.net/files',
getBlockBlobClient: mockGetBlockBlobClient,
}));
+const mockGetSafeErrorMetadata = jest.fn(() => ({ type: 'Error', status: 403 }));
jest.mock('@librechat/data-schemas', () => ({
logger: { error: jest.fn() },
@@ -12,12 +13,14 @@ jest.mock('@librechat/data-schemas', () => ({
jest.mock('@librechat/api', () => ({
deleteRagFile: jest.fn(),
assertRemoteFileURL: jest.fn((url) => url),
+ getSafeErrorMetadata: (...args) => mockGetSafeErrorMetadata(...args),
getAzureContainerClient: (...args) => mockGetAzureContainerClient(...args),
getRemoteFileFetchMaxBytes: jest.fn(() => 1024),
getRemoteFileFetchTimeoutMs: jest.fn(() => 1000),
assertRemoteFileContentLength: jest.fn(),
}));
+const { logger } = require('@librechat/data-schemas');
const { getAzureFileStream } = require('./crud');
describe('getAzureFileStream', () => {
@@ -54,4 +57,22 @@ describe('getAzureFileStream', () => {
expect(mockGetAzureContainerClient).toHaveBeenCalledWith();
expect(mockGetBlockBlobClient).toHaveBeenCalledWith('uploads/user/report one.pdf');
});
+
+ it('logs bounded metadata without the signed blob URL', async () => {
+ const signedUrl =
+ 'https://account.blob.core.windows.net/files/uploads/user/report.pdf?sig=secret';
+ const failure = Object.assign(new Error(`Request failed for ${signedUrl}`), {
+ statusCode: 403,
+ });
+ mockDownload.mockRejectedValue(failure);
+
+ await expect(getAzureFileStream({}, signedUrl)).rejects.toBe(failure);
+
+ expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(failure);
+ expect(logger.error).toHaveBeenCalledWith('[getAzureFileStream] Error getting blob stream:', {
+ type: 'Error',
+ status: 403,
+ });
+ expect(JSON.stringify(logger.error.mock.calls)).not.toContain(signedUrl);
+ });
});
diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js
index ec28e907abc..99404f54046 100644
--- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js
+++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js
@@ -5,6 +5,7 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/agents', () => ({
+ ...jest.requireActual('@librechat/agents'),
getCodeBaseURL: jest.fn(() => 'http://localhost:8000'),
}));
@@ -20,6 +21,7 @@ jest.mock('@librechat/api', () => {
const http = require('http');
const https = require('https');
return {
+ processCodeOutput: jest.requireActual('@librechat/api').processCodeOutput,
resolveDownloadPath: (file) => file.storageKey || file.filepath,
logAxiosError: jest.fn(),
getBasePath: jest.fn(() => ''),
@@ -47,7 +49,7 @@ jest.mock('@librechat/api', () => {
withTimeout: async (promise) => promise,
/* These traversal cases all use non-office filenames โ keep the
* inline (non-finalize) path so existing assertions on a single
- * createFile call hold. */
+ * commitCodeFile call hold. */
hasOfficeHtmlPath: jest.fn(() => false),
/* Identity-helper stub mirroring `packages/api/src/files/code/identity.ts`.
* `processCodeOutput` calls this for every output download URL;
@@ -78,6 +80,7 @@ jest.mock('~/models', () => ({
getFiles: jest.fn().mockResolvedValue([]),
updateFile: jest.fn(),
claimCodeFile: jest.fn().mockResolvedValue({ file_id: 'mock-uuid', usage: 0 }),
+ commitCodeFile: jest.fn().mockResolvedValue(true),
}));
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/user123/mock-uuid__output.csv');
@@ -105,7 +108,7 @@ jest.mock('~/server/services/Files/retention', () => ({
}));
const { getRetentionExpiry } = require('~/server/services/Files/retention');
-const { createFile } = require('~/models');
+const { commitCodeFile } = require('~/models');
const { processCodeOutput } = require('../process');
const baseParams = {
@@ -151,7 +154,7 @@ describe('processCodeOutput path traversal protection', () => {
mockSanitizeArtifactPath.mockReturnValueOnce('safe-output.csv');
await processCodeOutput({ ...baseParams, name: 'unsafe/../../output.csv' });
- const fileArg = createFile.mock.calls[0][0];
+ const fileArg = commitCodeFile.mock.calls[0][0];
expect(fileArg.filename).toBe('safe-output.csv');
expect(fileArg.tenantId).toBe('tenantA');
});
@@ -173,7 +176,7 @@ describe('processCodeOutput path traversal protection', () => {
await processCodeOutput({ ...baseParams, name: '../../../chart.png' });
expect(mockSanitizeArtifactPath).toHaveBeenCalledWith('../../../chart.png');
- const fileArg = createFile.mock.calls[0][0];
+ const fileArg = commitCodeFile.mock.calls[0][0];
expect(fileArg.filename).toBe('safe-chart.png');
expect(fileArg.tenantId).toBe('tenantA');
});
diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js
index f42da0e77ce..ed800aaf37a 100644
--- a/api/server/services/Files/Code/process.js
+++ b/api/server/services/Files/Code/process.js
@@ -1,5 +1,4 @@
const path = require('path');
-const { v4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { getCodeBaseURL } = require('@librechat/agents');
const {
@@ -24,6 +23,7 @@ const {
extractCodeArtifactText,
extractCodeArtifactRawText,
extractCodeArtifactInspectionText,
+ prepareCodeOutputBufferForInspection,
getBoundedCodeOutputByteLimit,
getExtractedTextFormat,
getStorageMetadata,
@@ -38,16 +38,14 @@ const {
CODE_OUTPUT_PREFLIGHT_MAX_BYTES,
CODE_OUTPUT_PREFLIGHT_MAX_COUNT,
normalizeArtifactDeliveryFailure,
+ processCodeOutput: processCodeOutputWithDeps,
resolveDownloadPath,
} = require('@librechat/api');
const {
Tools,
megabyte,
- fileConfig,
FileContext,
FileSources,
- imageExtRegex,
- inferMimeType,
EToolResources,
EModelEndpoint,
ErrorTypes,
@@ -56,7 +54,7 @@ const {
getEndpointFileConfig,
} = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
-const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models');
+const { getFiles, updateFile, claimCodeFile, commitCodeFile } = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert');
const { getRetentionExpiry } = require('~/server/services/Files/retention');
@@ -315,66 +313,16 @@ const prepareCodeOutputForInspection = async ({
executionProfile,
executionRouteKey,
});
- const safeName = sanitizeArtifactPath(name);
- const fallbackType = inferMimeType(name, '') || 'application/octet-stream';
- if (!inspectContent) {
- return {
- buffer,
- file: {
- name,
- filename: safeName,
- type: fallbackType,
- },
- };
- }
- if (buffer.length > fileSizeLimit) {
- return {
- buffer,
- extractedTextComplete: false,
- file: {
- name,
- filename: safeName,
- type: fallbackType,
- },
- };
- }
-
- const detectedType = await determineFileType(buffer, true);
- const detectedMimeType = detectedType?.mime?.toLowerCase();
- if (detectedMimeType?.startsWith('image/')) {
- return {
- buffer,
- extractedTextComplete: false,
- file: {
- name,
- filename: safeName,
- type: detectedMimeType,
- },
- };
- }
-
- const leafName = path.basename(safeName);
- const unknownText = detectedType == null ? extractCodeArtifactRawText(buffer, 'utf8-text') : null;
- const mimeType = unknownText != null ? 'text/plain' : (detectedMimeType ?? fallbackType);
- const category = unknownText != null ? 'utf8-text' : classifyCodeArtifact(leafName, mimeType);
- const content = unknownText ?? extractCodeArtifactRawText(buffer, category);
- const extractedText = await extractCodeArtifactInspectionText(
+ return prepareCodeOutputBufferForInspection({
buffer,
- leafName,
- mimeType,
- category,
- );
- return {
- buffer,
- extractedTextComplete: extractedText.complete,
- file: {
- name,
- filename: safeName,
- type: mimeType,
- content: content ?? undefined,
- extractedText: extractedText.text ?? undefined,
- },
- };
+ name,
+ fileSizeLimit,
+ inspectContent,
+ determineFileType,
+ classify: classifyCodeArtifact,
+ extractRawText: extractCodeArtifactRawText,
+ extractInspectionText: extractCodeArtifactInspectionText,
+ });
};
/**
@@ -636,490 +584,28 @@ const runPreviewFinalize = ({ finalize, fileId, previewRevision, onResolved }) =
});
};
-/**
- * Process code execution output files โ downloads and saves both images
- * and non-image files. All files are saved to local storage with
- * `codeEnvRef` metadata for code env re-upload.
- *
- * Returns a two-part shape so callers can ship the attachment to the
- * client immediately and run preview extraction in the background:
- * - `file`: persisted metadata (file is on disk, downloadable, and
- * has `status: 'pending'` if a preview is still being rendered).
- * - `finalize` (optional): a thunk returning the deferred preview
- * result promise. Present only when an inline HTML preview is
- * expected (office buckets โ DOCX/XLSX/XLS/ODS/CSV/PPTX). Caller
- * decides whether to await or fire-and-forget.
- *
- * Existing fallback paths (size limit, missing storage strategy, error
- * catch) return `{ file }` with no `finalize` โ there's nothing to
- * extract.
- *
- * @param {ServerRequest} params.req - The Express request object.
- * @param {string} params.id - The file ID from the code environment.
- * @param {string} params.name - The filename.
- * @param {string} params.toolCallId - The tool call ID that generated the file.
- * @param {string} params.session_id - The code execution session ID.
- * @param {string} params.conversationId - The current conversation ID.
- * @param {string} params.messageId - The current message ID.
- * @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
- * @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
- * @param {string} [params.executionRouteKey] - Trusted deployment-local route identity.
- * @param {string} [params.bridgeWorkerId] - Trusted bridge worker selected for this execution.
- * @param {Buffer} [params.preparedBuffer] - Bytes downloaded during a
- * no-write content inspection preflight.
- * @param {boolean} [params.downloadFallback] - Return the bounded download
- * fallback without downloading the generated bytes again.
- * @returns {Promise<{ file: MongoFile & { messageId: string, toolCallId: string }, finalize?: () => Promise }>}
- */
-const processCodeOutput = async ({
- req,
- id,
- name,
- toolCallId,
- conversationId,
- messageId,
- session_id,
- agentId,
- freshClaimAfter,
- codeApiBaseUrl,
- executionProfile = 'default',
- executionRouteKey = executionProfile,
- bridgeWorkerId,
- preparedBuffer,
- downloadFallback,
-}) => {
- const appConfig = req.config;
- const currentDate = new Date();
- const fileExt = path.extname(name).toLowerCase();
- const isImage = fileExt && imageExtRegex.test(name);
-
- const { endpointFileConfig, fileSizeLimit } = getCodeOutputFileSettings(req);
-
- try {
- const formattedDate = currentDate.toISOString();
- if (downloadFallback === true) {
- return {
- file: createDownloadFallback({
- id,
- name,
- agentId,
- messageId,
- toolCallId,
- session_id,
- conversationId,
- executionProfile,
- executionRouteKey,
- expiresAt: currentDate.getTime() + 86400000,
- }),
- };
- }
- const retentionExpiryPromise = getRetentionExpiry(req);
- const buffer =
- preparedBuffer ??
- (await downloadCodeOutputBuffer({
- req,
- id,
- session_id,
- maxBytes: fileSizeLimit,
- codeApiBaseUrl,
- executionProfile,
- bridgeWorkerId,
- }));
-
- // Enforce file size limit
- if (buffer.length > fileSizeLimit) {
- logger.warn(
- `[processCodeOutput] File "${name}" (${(buffer.length / megabyte).toFixed(2)} MB) exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`,
- );
- return {
- file: createDownloadFallback({
- id,
- name,
- agentId,
- messageId,
- toolCallId,
- session_id,
- conversationId,
- executionProfile,
- executionRouteKey,
- expiresAt: currentDate.getTime() + 86400000,
- }),
- };
- }
-
- /* Code-output files belong to the user who ran the execution.
- * SessionKey on codeapi will be `:user:` for these,
- * so cache and access stay user-private. */
- const codeEnvRef = {
- kind: 'user',
- id: req.user.id,
- storage_session_id: session_id,
- file_id: id,
- executionProfile,
- ...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}),
- };
-
- /* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`)
- * so the next prime() can place the file at the same nested path in the
- * sandbox; flattening would re-create the bug where every nested artifact
- * collapsed into the root and read_file calls 404'd. The flat-form
- * storage key is composed below once `file_id` is known so we can cap
- * the total length at filesystem NAME_MAX. */
- const safeName = sanitizeArtifactPath(name);
- if (safeName !== name) {
- logger.warn(
- `[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`,
- );
- }
-
- /**
- * Atomically claim a file_id for this (filename, conversationId, context) tuple.
- * Uses $setOnInsert so concurrent calls for the same filename converge on
- * a single record instead of creating duplicates (TOCTOU race fix).
- *
- * Claim by `safeName` (not raw `name`) so the claim and the eventual
- * `createFile` agree on the filename column โ otherwise weird inputs
- * (e.g. `"proj name/file@v1.txt"`) would claim under the raw name and
- * then write under the sanitized one, leaving the claim row orphaned.
- */
- /**
- * Dispatch-order stamp persisted with every write AND every claim insert
- * (foreground writes dispatch โ now): the out-of-order guard below
- * compares WRITER dispatch order, not wall-clock write time โ an older
- * task writing late must not make a newer task's harvest look stale, and
- * a freshly claimed row must carry its claimant's stamp before the
- * content write lands.
- */
- const sourceDispatchedAt = freshClaimAfter ?? Date.now();
-
- const newFileId = v4();
- const claimed = await claimCodeFile({
- filename: safeName,
- conversationId,
- file_id: newFileId,
- user: req.user.id,
- tenantId: req.user.tenantId,
- sourceDispatchedAt,
- });
- const file_id = claimed.file_id;
- const isUpdate = file_id !== newFileId;
-
- /**
- * Out-of-order guard for detached (background) harvests: when the claimed
- * row's last writer was dispatched AFTER this task (`freshClaimAfter` =
- * this task's dispatch time), a newer run owns this filename slot. The
- * `(filename, conversationId)` unique index means the stale bytes have
- * nowhere else to live, so skip this file rather than overwrite fresh
- * content โ the harvest's stdout patch still lands, only the superseded
- * attachment is omitted. Falls back to `updatedAt` for rows written
- * before the stamp existed (the claim itself is timestamp-neutral).
- */
- const lastWriterDispatchedAt =
- claimed.metadata?.sourceDispatchedAt ??
- (claimed.updatedAt != null ? new Date(claimed.updatedAt).getTime() : null);
- if (isUpdate && freshClaimAfter != null && lastWriterDispatchedAt > freshClaimAfter) {
- logger.warn(
- `[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`,
- );
- return null;
- }
-
- if (isUpdate) {
- logger.debug(
- `[processCodeOutput] Updating existing file "${safeName}" (${file_id}) instead of creating duplicate`,
- );
- }
-
- /**
- * Background harvests commit through a CONDITIONAL write: the ownership
- * predicate (last writer's dispatch stamp not newer than ours) is part of
- * the update's filter, so check and write are one atomic operation โ a
- * stale harvest's commit simply misses and its attachment is skipped.
- * The row always exists here (the claim inserted it), so the non-upsert
- * `updateFile` matches `createFile(data, true)` semantics ($set + TTL
- * unset). Bytes a loser may have already uploaded to the shared storage
- * key are a narrow residual that per-file locking would be needed to
- * close. Foreground writes keep the unconditional `createFile` path.
- */
- const commitCodeFile = async (fileData) => {
- if (freshClaimAfter == null) {
- await createFile(fileData, true);
- return true;
- }
- const committed = await updateFile(fileData, {
- $or: [
- { 'metadata.sourceDispatchedAt': { $exists: false } },
- { 'metadata.sourceDispatchedAt': { $lte: sourceDispatchedAt } },
- ],
- });
- if (!committed) {
- logger.warn(
- `[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`,
- );
- return false;
- }
- return true;
- };
-
- /**
- * Preserve the original `messageId` on update. Each `processCodeOutput`
- * call would otherwise overwrite it with the current run's run id, which
- * decouples the file from the assistant message that originally created
- * it. `getCodeGeneratedFiles` filters by `messageId IN `, so a
- * stale id (e.g. from a later regeneration / failed re-read attempt)
- * silently excludes the file from priming on subsequent turns.
- */
- const persistedMessageId = isUpdate ? (claimed.messageId ?? messageId) : messageId;
- /* A generated-output write replaces the file's bytes, so pointers to
- * earlier content in another profile must not survive as reusable refs. */
- const codeEnvReferenceSet = mergeCodeEnvRef(undefined, codeEnvRef);
- const codeEnvMetadata = {
- ...claimed.metadata,
- ...codeEnvReferenceSet,
- sourceDispatchedAt,
- };
-
- if (isImage) {
- const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
- const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
- const filepath = usage > 1 ? `${_file.filepath}?v=${Date.now()}` : _file.filepath;
- const storageMetadata = getStorageMetadata({
- filepath: _file.filepath,
- source: appConfig.fileStrategy,
- storageKey: _file.storageKey,
- storageRegion: _file.storageRegion,
- });
- const file = {
- ..._file,
- filepath,
- ...storageMetadata,
- file_id,
- messageId: persistedMessageId,
- usage,
- filename: safeName,
- conversationId,
- executionProfile,
- user: req.user.id,
- tenantId: req.user.tenantId,
- type: `image/${appConfig.imageOutputType}`,
- createdAt: isUpdate ? claimed.createdAt : formattedDate,
- updatedAt: formattedDate,
- source: appConfig.fileStrategy,
- context: FileContext.execute_code,
- metadata: codeEnvMetadata,
- ...(await retentionExpiryPromise),
- };
- if (!(await commitCodeFile(file))) {
- return null;
- }
- return { file: Object.assign(file, { messageId, toolCallId, agentId }) };
- }
-
- const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy);
- if (!saveBuffer) {
- logger.warn(
- `[processCodeOutput] saveBuffer not available for strategy ${appConfig.fileStrategy}, falling back to download URL`,
- );
- return {
- file: createDownloadFallback({
- id,
- name,
- agentId,
- messageId,
- toolCallId,
- session_id,
- conversationId,
- executionProfile,
- executionRouteKey,
- expiresAt: currentDate.getTime() + 86400000,
- }),
- };
- }
-
- const detectedType = await determineFileType(buffer, true);
- const mimeType = detectedType?.mime || inferMimeType(name, '') || 'application/octet-stream';
-
- /** Check MIME type support - for code-generated files, we're lenient but log unsupported types */
- const isSupportedMimeType = fileConfig.checkType(
- mimeType,
- endpointFileConfig.supportedMimeTypes,
- );
- if (!isSupportedMimeType) {
- logger.warn(
- `[processCodeOutput] File "${name}" has unsupported MIME type "${mimeType}", proceeding with storage but may not be usable as tool resource`,
- );
- }
-
- /* Compose the storage key here, after `file_id` is known, so the
- * `flattenArtifactPath` cap budget can be calculated against the
- * actual prefix length. The full key has to fit in one filesystem
- * path component (NAME_MAX = 255 on most filesystems); without this
- * cap, deeply-nested artifact paths whose individual segments were
- * within bounds can still produce a flat form that overflows once
- * `${file_id}__` is prepended, causing `ENAMETOOLONG` inside
- * saveBuffer and falling back to a download URL. The 255 figure is
- * the conservative cross-platform NAME_MAX (Linux ext4, NTFS, APFS).
- */
- const NAME_MAX = 255;
- const flatName = flattenArtifactPath(safeName, NAME_MAX - file_id.length - 2);
- const fileName = `${file_id}__${flatName}`;
- const filepath = await saveBuffer({
- userId: req.user.id,
- buffer,
- fileName,
- basePath: 'uploads',
- tenantId: req.user.tenantId,
- });
- const storageMetadata = getStorageMetadata({
- filepath,
- source: appConfig.fileStrategy,
- });
-
- /* `classifyCodeArtifact` and `extractCodeArtifactText` make
- * extension/bare-name decisions on the input string. With the
- * path-preserving sanitizer they can now receive a nested path like
- * `reports.v1/Makefile`, which the classifier's `extensionOf` reads
- * as `v1/Makefile` (the slice after the dot in the directory name)
- * and the bare-name branch rejects because it sees a `.` anywhere in
- * the string. Result: extensionless artifacts under dotted folders
- * (Makefile, Dockerfile, etc.) get misclassified as `other` and
- * skip text extraction. Pass the basename so classification matches
- * what it would have gotten with the old flat-name flow. */
- const leafName = path.basename(safeName);
- const category = classifyCodeArtifact(leafName, mimeType);
-
- /* Office-bucket files (DOCX/XLSX/XLS/ODS/CSV/PPTX) route through
- * `bufferToOfficeHtml` which is CPU-heavy. Persist the record now
- * with `status: 'pending'` and `text: null` so the agent's response
- * isn't blocked, then return a `finalize` thunk the caller can run
- * in the background. Non-office files have cheap or no extraction
- * โ run it inline so the caller gets a fully-resolved record
- * without juggling a finalize step. */
- const expectsPreview = hasOfficeHtmlPath(leafName, mimeType);
-
- const baseFile = {
- file_id,
- filepath,
- ...storageMetadata,
- messageId: persistedMessageId,
- object: 'file',
- filename: safeName,
- type: mimeType,
- conversationId,
- user: req.user.id,
- tenantId: req.user.tenantId,
- bytes: buffer.length,
- updatedAt: formattedDate,
- metadata: codeEnvMetadata,
- source: appConfig.fileStrategy,
- context: FileContext.execute_code,
- usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
- createdAt: isUpdate ? claimed.createdAt : formattedDate,
- ...(await retentionExpiryPromise),
- };
-
- if (expectsPreview) {
- /* Persist with `status: 'pending'` and explicit
- * `text: null` / `textFormat: null` so an update that previously
- * had cached text gets cleared. The deferred finalize transitions
- * to 'ready' (with text/textFormat) or 'failed' (with
- * previewError).
- *
- * `previewRevision` is a fresh UUID stamped on every emit. The
- * deferred finalize's `updateFile` is conditional on this โ if
- * a newer turn (cross-turn filename reuse) has rotated the
- * revision before this render finishes, the stale render is
- * silently discarded rather than overwriting the newer record.
- * (Codex P1 review on PR #12957.) */
- const previewRevision = v4();
- const file = {
- ...baseFile,
- text: null,
- textFormat: null,
- status: 'pending',
- previewError: null,
- previewRevision,
- };
- if (!(await commitCodeFile(file))) {
- return null;
- }
- return {
- file: Object.assign(file, { messageId, toolCallId, agentId }),
- finalize: () =>
- finalizePreview({ buffer, leafName, mimeType, category, file_id, previewRevision }),
- previewRevision,
- };
- }
-
- /* Non-office path: extraction is cheap (utf8 decode, parseDocument
- * for PDF/ODT, or null for binaries). Run inline and return a
- * fully-resolved record โ no `finalize` needed. */
- const text = await extractCodeArtifactText(buffer, leafName, mimeType, category);
- /* `textFormat` accompanies `text` so the client can gate
- * office-HTML-bucket routing on a trusted signal โ clients MUST
- * NOT inject `text` into the iframe as HTML unless `textFormat ===
- * 'html'`. RAG-uploaded `.docx` etc. arrive with plain text from
- * mammoth.extractRawText and would otherwise be hijacked by the
- * extension-based office routing into the HTML-injection path
- * (Codex P1 review on PR #12934). null on extract failure โ the
- * client treats absence as 'text' for safety. */
- const textFormat = getExtractedTextFormat(leafName, mimeType, text);
- const file = {
- ...baseFile,
- // Always set explicitly so an update which produces a binary or
- // oversized artifact clears any previously cached text โ createFile
- // uses findOneAndUpdate with $set semantics.
- text: text ?? null,
- textFormat: textFormat ?? null,
- // Clear deferred-preview lifecycle fields in case the prior emit
- // at this (filename, conversationId) was an office file โ
- // otherwise stale `pending`/`failed` would persist and the client
- // would render the wrong state for the now non-office artifact.
- status: null,
- previewError: null,
- previewRevision: null,
- };
-
- if (!(await commitCodeFile(file))) {
- return null;
- }
- return { file: Object.assign(file, { messageId, toolCallId, agentId }) };
- } catch (error) {
- if (error?.code === 'CODE_OUTPUT_DOWNLOAD_LIMIT') {
- logger.warn(
- `[processCodeOutput] Generated file exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`,
- );
- }
- if (error?.message === 'Path traversal detected in filename') {
- logger.warn(
- `[processCodeOutput] Path traversal blocked for file "${name}" | conv=${conversationId}`,
- );
- }
- logAxiosError({
- message: 'Error downloading/processing code environment file',
- error,
- });
- logger.warn(
- `[processCodeOutput] Falling back to Code API download URL for strategy ${appConfig.fileStrategy}`,
- );
-
- // Fallback for download errors - return download URL so user can still manually download
- return {
- file: createDownloadFallback({
- id,
- name,
- agentId,
- messageId,
- toolCallId,
- session_id,
- conversationId,
- executionProfile,
- executionRouteKey,
- expiresAt: currentDate.getTime() + 86400000,
- }),
- };
- }
-};
+const processCodeOutput = (params) =>
+ processCodeOutputWithDeps(params, {
+ getCodeOutputFileSettings,
+ downloadCodeOutputBuffer,
+ createDownloadFallback,
+ getRetentionExpiry,
+ convertImage,
+ getStrategyFunctions,
+ determineFileType,
+ claimCodeFile,
+ commitCodeFile,
+ finalizePreview,
+ hasOfficeHtmlPath,
+ sanitizeArtifactPath,
+ flattenArtifactPath,
+ classifyCodeArtifact,
+ extractCodeArtifactText,
+ getExtractedTextFormat,
+ getStorageMetadata,
+ logAxiosError,
+ logger,
+ });
function getSessionFileInfo(ref, req, route = {}, signal) {
return getCodeFileInfo({
diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js
index e1508e665f0..0a87df47bcc 100644
--- a/api/server/services/Files/Code/process.spec.js
+++ b/api/server/services/Files/Code/process.spec.js
@@ -64,6 +64,9 @@ const mockParseSandboxImageChunk = jest.fn((response) => response);
const passthroughWithTimeout = async (promise) => promise;
jest.mock('@librechat/api', () => {
return {
+ processCodeOutput: jest.requireActual('@librechat/api').processCodeOutput,
+ prepareCodeOutputBufferForInspection:
+ jest.requireActual('@librechat/api').prepareCodeOutputBufferForInspection,
resolveDownloadPath: (file) => file.storageKey || file.filepath,
logAxiosError: jest.fn(),
/* Behaviourally identical to the real predicate in
@@ -204,12 +207,14 @@ jest.mock('@librechat/agents', () => ({
// Mock models
const mockClaimCodeFile = jest.fn();
+const mockCommitCodeFile = jest.fn();
const mockUpdateFile = jest.fn();
jest.mock('~/models', () => ({
createFile: jest.fn().mockResolvedValue({}),
getFiles: jest.fn(),
updateFile: mockUpdateFile,
claimCodeFile: (...args) => mockClaimCodeFile(...args),
+ commitCodeFile: mockCommitCodeFile,
}));
// Mock permissions (must be before process.js import)
@@ -298,6 +303,8 @@ describe('Code Process', () => {
});
getFiles.mockResolvedValue(null);
createFile.mockResolvedValue({});
+ mockCommitCodeFile.mockReset();
+ mockCommitCodeFile.mockResolvedValue(true);
getStrategyFunctions.mockReturnValue({
saveBuffer: jest.fn().mockResolvedValue('/uploads/mock-file-path.txt'),
});
@@ -329,7 +336,7 @@ describe('Code Process', () => {
},
});
expect(mockClaimCodeFile).not.toHaveBeenCalled();
- expect(createFile).not.toHaveBeenCalled();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
expect(getStrategyFunctions).not.toHaveBeenCalled();
});
@@ -340,7 +347,7 @@ describe('Code Process', () => {
expect(mockAxios).not.toHaveBeenCalled();
expect(mockClaimCodeFile).toHaveBeenCalledTimes(1);
- expect(createFile).toHaveBeenCalledTimes(1);
+ expect(mockCommitCodeFile).toHaveBeenCalledTimes(1);
});
it('enforces a caller-provided aggregate inspection budget while downloading', async () => {
@@ -360,7 +367,7 @@ describe('Code Process', () => {
}),
);
expect(mockClaimCodeFile).not.toHaveBeenCalled();
- expect(createFile).not.toHaveBeenCalled();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
});
it('extracts text bytes even when the generated filename spoofs an image extension', async () => {
@@ -400,7 +407,150 @@ describe('Code Process', () => {
});
expect(mockAxios).not.toHaveBeenCalled();
expect(mockClaimCodeFile).not.toHaveBeenCalled();
- expect(createFile).not.toHaveBeenCalled();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('published run artifacts', () => {
+ const scope = {
+ userId: 'user-123',
+ conversationId: 'conv-123',
+ runId: 'parent-run',
+ executionId: 'child-run',
+ agentId: 'child-agent',
+ sourceFileId: 'file-id-123',
+ };
+ const provenance = {
+ runId: scope.runId,
+ executionId: scope.executionId,
+ agentId: scope.agentId,
+ sourceFileId: scope.sourceFileId,
+ parentExecutionId: 'parent-execution',
+ publishedAt: '2026-09-11T16:00:00.000Z',
+ inputFileIds: ['input-pdf'],
+ };
+
+ it('stores the existing output pipeline result through the publication boundary', async () => {
+ const publish = jest.fn(async ({ file }) => ({
+ ...file,
+ file_id: 'published-id',
+ user: scope.userId,
+ conversationId: scope.conversationId,
+ context: FileContext.run_artifact,
+ metadata: { ...file.metadata, runFile: provenance },
+ }));
+ const discard = jest.fn();
+ const result = await processCodeOutput({
+ ...baseParams,
+ preparedBuffer: Buffer.from('report data'),
+ publication: { scope, provenance, publish, find: jest.fn(), discard },
+ });
+ expect(result.file).toMatchObject({
+ file_id: 'published-id',
+ filename: baseParams.name,
+ context: FileContext.run_artifact,
+ metadata: { runFile: provenance },
+ });
+ expect(publish).toHaveBeenCalledWith(expect.objectContaining({ scope, provenance }));
+ expect(mockClaimCodeFile).not.toHaveBeenCalled();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
+ expect(discard).not.toHaveBeenCalled();
+ });
+
+ it('finalizes office previews against the canonical published file identity', async () => {
+ mockHasOfficeHtmlPath.mockReturnValueOnce(true);
+ const publish = jest.fn(async ({ file }) => ({
+ ...file,
+ file_id: 'published-office-id',
+ user: scope.userId,
+ conversationId: scope.conversationId,
+ context: FileContext.run_artifact,
+ metadata: { ...file.metadata, runFile: provenance },
+ }));
+ const result = await processCodeOutput({
+ ...baseParams,
+ name: 'report.csv',
+ preparedBuffer: Buffer.from('a,b\n1,2'),
+ publication: { scope, provenance, publish, find: jest.fn(), discard: jest.fn() },
+ });
+ expect(result.file.file_id).toBe('published-office-id');
+ expect(result.file.status).toBe('pending');
+ await result.finalize();
+ expect(require('~/models').updateFile).toHaveBeenCalledWith(
+ expect.objectContaining({ file_id: 'published-office-id' }),
+ { previewRevision: result.previewRevision },
+ );
+ });
+
+ it('cleans a stored publication attempt when classification fails before metadata commit', async () => {
+ const { createRunArtifactPublisher } = jest.requireActual('@librechat/api');
+ const discard = jest.fn(async () => undefined);
+ const publishRunArtifactFile = jest.fn();
+ mockClassifyCodeArtifact.mockImplementationOnce(() => {
+ throw new Error('Classification failed after storage');
+ });
+ const publish = createRunArtifactPublisher({
+ claimRunArtifactFile: async () => ({ file_id: 'published-id' }),
+ publishRunArtifactFile,
+ findRunArtifactFile: async () => null,
+ processCodeOutput: (input) => processCodeOutput({ ...input, req: mockReq }),
+ prepare: async () => Buffer.from('report data'),
+ discard,
+ finalize: jest.fn(),
+ });
+ await expect(
+ publish({
+ scope,
+ provenance,
+ artifact: { id: baseParams.id, name: baseParams.name, sessionId: baseParams.session_id },
+ }),
+ ).rejects.toThrow('durable storage');
+ expect(publishRunArtifactFile).not.toHaveBeenCalled();
+ expect(discard).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file_id: 'mock-uuid-1234',
+ filepath: '/uploads/mock-file-path.txt',
+ metadata: undefined,
+ }),
+ );
+ expect(discard).toHaveBeenCalledTimes(1);
+ });
+
+ it('cleans a generated image cancelled after conversion without publishing it', async () => {
+ const { createRunArtifactPublisher } = jest.requireActual('@librechat/api');
+ const controller = new AbortController();
+ const discard = jest.fn(async () => undefined);
+ const publishRunArtifactFile = jest.fn();
+ convertImage.mockImplementationOnce(async () => {
+ controller.abort(new Error('Publication generation expired'));
+ return { filepath: '/uploads/generated.webp', bytes: 12, width: 2, height: 2 };
+ });
+ const publish = createRunArtifactPublisher({
+ claimRunArtifactFile: async () => ({ file_id: 'published-id' }),
+ publishRunArtifactFile,
+ findRunArtifactFile: async () => null,
+ processCodeOutput: (input) => processCodeOutput({ ...input, req: mockReq }),
+ prepare: async () => Buffer.from('image data'),
+ discard,
+ finalize: jest.fn(),
+ });
+ await expect(
+ publish({
+ scope,
+ provenance,
+ artifact: { id: baseParams.id, name: 'generated.png', sessionId: baseParams.session_id },
+ signal: controller.signal,
+ }),
+ ).rejects.toThrow('generation expired');
+ expect(publishRunArtifactFile).not.toHaveBeenCalled();
+ expect(discard).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filepath: '/uploads/generated.webp',
+ file_id: 'mock-uuid-1234',
+ metadata: undefined,
+ }),
+ );
+ expect(discard).toHaveBeenCalledTimes(1);
});
});
@@ -465,6 +615,7 @@ describe('Code Process', () => {
});
expect(result).toBeNull();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
});
it('still reuses the claim when it predates the background run', async () => {
@@ -473,7 +624,7 @@ describe('Code Process', () => {
filename: 'test-file.txt',
updatedAt: '2024-01-01T00:00:00.000Z',
});
- mockUpdateFile.mockResolvedValue({ file_id: 'existing-file-id' });
+ mockCommitCodeFile.mockResolvedValue(true);
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const { file: result } = await processCodeOutput({
@@ -482,6 +633,10 @@ describe('Code Process', () => {
});
expect(result.file_id).toBe('existing-file-id');
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
+ expect.objectContaining({ file_id: 'existing-file-id' }),
+ new Date('2024-01-02T00:00:00.000Z').getTime(),
+ );
});
it('skips when a newer task holds an unwritten claim (insert stamp, no updatedAt yet)', async () => {
@@ -502,17 +657,15 @@ describe('Code Process', () => {
});
expect(result).toBeNull();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
});
- it('commits background writes conditionally: an inserter overtaken mid-flight misses', async () => {
- /* This task INSERTED the claim, then a newer task stamped and wrote
- * while this one was still downloading โ the ownership predicate is in
- * the write's own filter, so the commit atomically misses. */
+ it('omits output when the commit boundary rejects an overtaken claim', async () => {
mockClaimCodeFile.mockResolvedValue({
file_id: 'mock-uuid-1234',
user: 'user-123',
});
- mockUpdateFile.mockResolvedValueOnce(null);
+ mockCommitCodeFile.mockResolvedValueOnce(false);
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const result = await processCodeOutput({
@@ -521,18 +674,14 @@ describe('Code Process', () => {
});
expect(result).toBeNull();
- expect(mockUpdateFile).toHaveBeenCalledWith(
- expect.objectContaining({ file_id: 'mock-uuid-1234' }),
- {
- $or: [
- { 'metadata.sourceDispatchedAt': { $exists: false } },
- {
- 'metadata.sourceDispatchedAt': {
- $lte: new Date('2024-01-01T00:00:00.000Z').getTime(),
- },
- },
- ],
- },
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
+ expect.objectContaining({
+ file_id: 'mock-uuid-1234',
+ metadata: expect.objectContaining({
+ sourceDispatchedAt: new Date('2024-01-01T00:00:00.000Z').getTime(),
+ }),
+ }),
+ new Date('2024-01-01T00:00:00.000Z').getTime(),
);
});
@@ -542,7 +691,7 @@ describe('Code Process', () => {
filename: 'test-file.txt',
updatedAt: '2024-01-01T00:00:00.000Z',
});
- mockUpdateFile.mockResolvedValueOnce({ file_id: 'existing-file-id' });
+ mockCommitCodeFile.mockResolvedValueOnce(true);
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const { file: result } = await processCodeOutput({
@@ -565,7 +714,7 @@ describe('Code Process', () => {
sourceDispatchedAt: new Date('2024-01-01T00:00:00.000Z').getTime(),
},
});
- mockUpdateFile.mockResolvedValue({ file_id: 'existing-file-id' });
+ mockCommitCodeFile.mockResolvedValue(true);
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
const { file: result } = await processCodeOutput({
@@ -641,9 +790,9 @@ describe('Code Process', () => {
expect(mockClaimCodeFile).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenantA' }),
);
- expect(createFile).toHaveBeenCalledWith(
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenantA' }),
- true,
+ undefined,
);
});
@@ -748,7 +897,7 @@ describe('Code Process', () => {
storageRegion: 'us-east-2',
status: 'pending',
});
- expect(createFile).toHaveBeenCalledWith(
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
expect.objectContaining({
file_id: 'mock-uuid-1234',
user: 'user-123',
@@ -757,7 +906,7 @@ describe('Code Process', () => {
storageKey,
storageRegion: 'us-east-2',
}),
- true,
+ undefined,
);
expect(typeof finalize).toBe('function');
});
@@ -783,9 +932,9 @@ describe('Code Process', () => {
expect(mockSaveBuffer).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenantA' }),
);
- expect(createFile).toHaveBeenCalledWith(
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenantA' }),
- true,
+ undefined,
);
});
@@ -917,9 +1066,9 @@ describe('Code Process', () => {
'utf8-text',
);
expect(result.text).toBe('hello world\n');
- expect(createFile).toHaveBeenCalledWith(
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
expect.objectContaining({ text: 'hello world\n' }),
- true,
+ undefined,
);
});
@@ -933,7 +1082,7 @@ describe('Code Process', () => {
const { file: result } = await processCodeOutput({ ...baseParams, name: 'archive.zip' });
expect(result.text).toBeNull();
- const createCall = createFile.mock.calls[0][0];
+ const createCall = mockCommitCodeFile.mock.calls[0][0];
expect(createCall.text).toBeNull();
});
@@ -955,7 +1104,7 @@ describe('Code Process', () => {
await processCodeOutput({ ...baseParams, name: 'output.bin' });
// null (not omitted) so $set clears any prior `text` value.
- const createCall = createFile.mock.calls[0][0];
+ const createCall = mockCommitCodeFile.mock.calls[0][0];
expect(createCall).toHaveProperty('text', null);
});
@@ -989,7 +1138,7 @@ describe('Code Process', () => {
await processCodeOutput({ ...baseParams, name: 'output.txt' });
- const createCall = createFile.mock.calls[0][0];
+ const createCall = mockCommitCodeFile.mock.calls[0][0];
expect(createCall).toHaveProperty('status', null);
expect(createCall).toHaveProperty('previewError', null);
expect(createCall).toHaveProperty('previewRevision', null);
@@ -1010,7 +1159,7 @@ describe('Code Process', () => {
}),
);
expect(mockClaimCodeFile).toHaveBeenCalledTimes(1);
- expect(createFile).toHaveBeenCalledTimes(1);
+ expect(mockCommitCodeFile).toHaveBeenCalledTimes(1);
});
it('should fallback to download URL when file exceeds size limit', async () => {
@@ -1031,8 +1180,8 @@ describe('Code Process', () => {
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('exceeds size limit'));
expect(result.filepath).toContain('/api/files/code/download/session-123/file-id-123');
expect(result.expiresAt).toBeDefined();
- // Should not call createFile for oversized files (fallback path)
- expect(createFile).not.toHaveBeenCalled();
+ // Oversized files use the download fallback without committing metadata.
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
// Reset to default for other tests
fileSizeLimitConfig.value = 20 * 1024 * 1024;
@@ -1049,7 +1198,7 @@ describe('Code Process', () => {
expect.stringContaining('Generated file exceeds size limit'),
);
expect(mockClaimCodeFile).not.toHaveBeenCalled();
- expect(createFile).not.toHaveBeenCalled();
+ expect(mockCommitCodeFile).not.toHaveBeenCalled();
fileSizeLimitConfig.value = 20 * 1024 * 1024;
});
@@ -1262,18 +1411,18 @@ describe('Code Process', () => {
expect(result.messageId).toBe('msg-123');
});
- it('should call createFile with upsert enabled', async () => {
+ it('commits foreground output without a background dispatch constraint', async () => {
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
await processCodeOutput(baseParams);
- expect(createFile).toHaveBeenCalledWith(
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
expect.objectContaining({
file_id: 'mock-uuid-1234',
context: FileContext.execute_code,
}),
- true, // upsert flag
+ undefined,
);
});
});
@@ -1301,18 +1450,18 @@ describe('Code Process', () => {
*/
/**
- * `processCodeOutput` mutates the file object after `createFile` returns
+ * `processCodeOutput` mutates the file object after `commitCodeFile` returns
* (`Object.assign(file, { messageId, toolCallId })`) so the runtime
* caller sees the live messageId on the response. Reading
- * `createFile.mock.calls[0][0]` directly would therefore reflect the
+ * `mockCommitCodeFile.mock.calls[0][0]` directly would therefore reflect the
* post-mutation state because JS captures by reference. To assert
* what was actually PERSISTED, snapshot the args at call time.
*/
- function snapshotCreateFileArgs() {
+ function snapshotCommitCodeFileArgs() {
const snapshots = [];
- createFile.mockImplementation(async (file) => {
+ mockCommitCodeFile.mockImplementation(async (file) => {
snapshots.push({ ...file });
- return {};
+ return true;
});
return snapshots;
}
@@ -1325,7 +1474,7 @@ describe('Code Process', () => {
createdAt: '2024-01-01T00:00:00.000Z',
messageId: 'turn-1-original-msg',
});
- const persisted = snapshotCreateFileArgs();
+ const persisted = snapshotCommitCodeFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
@@ -1348,7 +1497,7 @@ describe('Code Process', () => {
createdAt: '2024-01-01T00:00:00.000Z',
// messageId intentionally absent
});
- const persisted = snapshotCreateFileArgs();
+ const persisted = snapshotCommitCodeFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
@@ -1367,7 +1516,7 @@ describe('Code Process', () => {
file_id: 'mock-uuid-1234',
user: 'user-123',
});
- const persisted = snapshotCreateFileArgs();
+ const persisted = snapshotCommitCodeFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
@@ -1391,7 +1540,7 @@ describe('Code Process', () => {
createdAt: '2024-01-01T00:00:00.000Z',
messageId: 'turn-1-original-msg',
});
- const persisted = snapshotCreateFileArgs();
+ const persisted = snapshotCommitCodeFileArgs();
const smallBuffer = Buffer.alloc(100);
mockAxios.mockResolvedValue({ data: smallBuffer });
@@ -1418,7 +1567,7 @@ describe('Code Process', () => {
createdAt: '2024-01-01T00:00:00.000Z',
messageId: 'turn-1-image-msg',
});
- const persisted = snapshotCreateFileArgs();
+ const persisted = snapshotCommitCodeFileArgs();
const imageBuffer = Buffer.alloc(500);
mockAxios.mockResolvedValue({ data: imageBuffer });
@@ -1599,9 +1748,9 @@ describe('Code Process', () => {
// Extractor MUST NOT have been called yet โ that's deferred preview work.
expect(mockExtractCodeArtifactText).not.toHaveBeenCalled();
// Persisted record with the pending status.
- expect(createFile).toHaveBeenCalledWith(
+ expect(mockCommitCodeFile).toHaveBeenCalledWith(
expect.objectContaining({ status: 'pending', text: null, textFormat: null }),
- true,
+ undefined,
);
});
diff --git a/api/server/services/Files/Firebase/crud.js b/api/server/services/Files/Firebase/crud.js
index cf6d4c74e3d..174b4737516 100644
--- a/api/server/services/Files/Firebase/crud.js
+++ b/api/server/services/Files/Firebase/crud.js
@@ -7,6 +7,7 @@ const {
deleteRagFile,
getFirebaseStorage,
assertRemoteFileURL,
+ getSafeErrorMetadata,
getRemoteFileFetchMaxBytes,
getRemoteFileFetchTimeoutMs,
assertRemoteFileContentLength,
@@ -265,7 +266,7 @@ async function getFirebaseFileStream(_req, filepath, { signal } = {}) {
return response.data;
} catch (error) {
- logger.error('Error getting Firebase file stream:', error);
+ logger.error('Error getting Firebase file stream:', getSafeErrorMetadata(error));
throw error;
}
}
diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js
index dc744542f12..8119c544c31 100644
--- a/api/server/services/Files/images/encode.js
+++ b/api/server/services/Files/images/encode.js
@@ -1,268 +1,10 @@
const axios = require('axios');
-const { logger } = require('@librechat/data-schemas');
-const { logAxiosError, validateImage, runGuardedEncode } = require('@librechat/api');
-const {
- FileSources,
- VisionModes,
- ImageDetail,
- ContentTypes,
- EModelEndpoint,
- mergeFileConfig,
- getEndpointFileConfig,
-} = require('librechat-data-provider');
+const { encodeAndFormatImages } = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
-/**
- * Converts a readable stream to a base64 encoded string.
- *
- * @param {NodeJS.ReadableStream} stream - The readable stream to convert.
- * @param {boolean} [destroyStream=true] - Whether to destroy the stream after processing.
- * @returns {Promise} - Promise resolving to the base64 encoded content.
- */
-async function streamToBase64(stream, destroyStream = true) {
- return new Promise((resolve, reject) => {
- const chunks = [];
-
- stream.on('data', (chunk) => {
- chunks.push(chunk);
- });
-
- stream.on('end', () => {
- try {
- const buffer = Buffer.concat(chunks);
- const base64Data = buffer.toString('base64');
- chunks.length = 0; // Clear the array
- resolve(base64Data);
- } catch (err) {
- reject(err);
- }
- });
-
- stream.on('error', (error) => {
- chunks.length = 0;
- reject(error);
- });
- }).finally(() => {
- // Clean up the stream if required
- if (destroyStream && stream.destroy && typeof stream.destroy === 'function') {
- stream.destroy();
- }
- });
-}
-
-/**
- * Fetches an image from a URL and returns its base64 representation.
- *
- * @async
- * @param {string} url The URL of the image.
- * @returns {Promise} The base64-encoded string of the image.
- * @throws {Error} If there's an issue fetching the image or encoding it.
- */
-async function fetchImageToBase64(url) {
- try {
- const response = await axios.get(url, {
- responseType: 'arraybuffer',
- });
- const base64Data = Buffer.from(response.data).toString('base64');
- response.data = null;
- return base64Data;
- } catch (error) {
- const message = 'Error fetching image to convert to base64';
- throw new Error(logAxiosError({ message, error }));
- }
-}
-
-const base64Only = new Set([
- EModelEndpoint.google,
- EModelEndpoint.anthropic,
- 'Ollama',
- 'ollama',
- EModelEndpoint.bedrock,
-]);
-
-const blobStorageSources = new Set([
- FileSources.azure_blob,
- FileSources.s3,
- FileSources.firebase,
- FileSources.cloudfront,
-]);
-
-/**
- * Encodes and formats the given files.
- * @param {ServerRequest} req - The request object.
- * @param {Array} files - The array of files to encode and format.
- * @param {object} params - Object containing provider/endpoint information
- * @param {Providers | EModelEndpoint | string} [params.provider] - The provider for the image
- * @param {string} [params.endpoint] - Optional: The endpoint for the image
- * @param {string} [params.imageDetail] - Optional: Detail level resolved by the caller, used
- * where the request body carries no conversation-level setting (the agents route).
- * @param {string} [mode] - Optional: The endpoint mode for the image.
- * @returns {Promise<{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }>} - A promise that resolves to the result object containing the encoded images and file details.
- */
-async function encodeAndFormat(req, files, params, mode) {
- const { provider, endpoint } = params;
- const effectiveEndpoint = endpoint ?? provider;
- const promises = [];
- /** @type {Record, 'prepareImagePayload' | 'getDownloadStream'>>} */
- const encodingMethods = {};
- /** @type {{ files: MongoFile[]; image_urls: MessageContentImageUrl[] }} */
- const result = {
- files: [],
- image_urls: [],
- };
-
- if (!files || !files.length) {
- return result;
- }
-
- for (let file of files) {
- /** @type {FileSources} */
- const source = file.source ?? FileSources.local;
-
- if (!file.height) {
- promises.push([file, null]);
- continue;
- }
-
- if (!encodingMethods[source]) {
- const { prepareImagePayload, getDownloadStream } = getStrategyFunctions(source);
- if (!prepareImagePayload) {
- throw new Error(`Encoding function not implemented for ${source}`);
- }
-
- encodingMethods[source] = { prepareImagePayload, getDownloadStream };
- }
-
- const preparePayload = encodingMethods[source].prepareImagePayload;
- /* We need to fetch the image and convert it to base64 if we are using S3/Azure Blob/Firebase storage. */
- if (blobStorageSources.has(source)) {
- try {
- const downloadStream = encodingMethods[source].getDownloadStream;
- let base64Data = await runGuardedEncode(file.bytes ?? 0, async () => {
- let stream = await downloadStream(req, file.filepath);
- const data = await streamToBase64(stream);
- stream = null;
- return data;
- });
- promises.push([file, base64Data]);
- base64Data = null;
- continue;
- } catch (error) {
- logger.error('Error processing image from blob storage:', error);
- }
- } else if (source !== FileSources.local && base64Only.has(effectiveEndpoint)) {
- const entry = await runGuardedEncode(file.bytes ?? 0, async () => {
- const [_file, imageURL] = await preparePayload(req, file);
- return [_file, await fetchImageToBase64(imageURL)];
- });
- promises.push(entry);
- continue;
- }
- promises.push(preparePayload(req, file));
- }
-
- const detail = params.imageDetail ?? req.body.imageDetail ?? ImageDetail.auto;
-
- /** @type {Array<[MongoFile, string]>} */
- const formattedImages = await Promise.all(promises);
- promises.length = 0;
-
- /** Extract configured file size limit from fileConfig for this endpoint */
- let configuredFileSizeLimit;
- if (req.config?.fileConfig) {
- const fileConfig = mergeFileConfig(req.config.fileConfig);
- const endpointConfig = getEndpointFileConfig({
- fileConfig,
- endpoint: effectiveEndpoint,
- });
- configuredFileSizeLimit = endpointConfig?.fileSizeLimit;
- }
-
- for (const [file, imageContent] of formattedImages) {
- const fileMetadata = {
- type: file.type,
- file_id: file.file_id,
- filepath: file.filepath,
- filename: file.filename,
- embedded: !!file.embedded,
- metadata: file.metadata,
- };
-
- if (file.height && file.width) {
- fileMetadata.height = file.height;
- fileMetadata.width = file.width;
- }
-
- if (!imageContent) {
- result.files.push(fileMetadata);
- continue;
- }
-
- /** Validate image buffer against size limits */
- if (file.height && file.width) {
- const imageBuffer = imageContent.startsWith('http')
- ? null
- : Buffer.from(imageContent, 'base64');
-
- if (imageBuffer) {
- const validation = await validateImage(
- imageBuffer,
- imageBuffer.length,
- effectiveEndpoint,
- configuredFileSizeLimit,
- );
-
- if (!validation.isValid) {
- throw new Error(`Image validation failed for ${file.filename}: ${validation.error}`);
- }
- }
- }
-
- const imagePart = {
- type: ContentTypes.IMAGE_URL,
- image_url: {
- url: imageContent.startsWith('http')
- ? imageContent
- : `data:${file.type};base64,${imageContent}`,
- detail,
- },
- };
-
- if (mode === VisionModes.agents) {
- result.image_urls.push({ ...imagePart });
- result.files.push({ ...fileMetadata });
- continue;
- }
-
- if (
- effectiveEndpoint &&
- effectiveEndpoint === EModelEndpoint.google &&
- mode === VisionModes.generative
- ) {
- delete imagePart.image_url;
- imagePart.inlineData = {
- mimeType: file.type,
- data: imageContent,
- };
- } else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.google) {
- imagePart.image_url = imagePart.image_url.url;
- } else if (effectiveEndpoint && effectiveEndpoint === EModelEndpoint.anthropic) {
- imagePart.type = 'image';
- imagePart.source = {
- type: 'base64',
- media_type: file.type,
- data: imageContent,
- };
- delete imagePart.image_url;
- }
-
- result.image_urls.push({ ...imagePart });
- result.files.push({ ...fileMetadata });
- }
- formattedImages.length = 0;
- return { ...result };
-}
+const dependencies = { getStrategyFunctions, httpClient: axios };
module.exports = {
- encodeAndFormat,
+ encodeAndFormat: (req, files, params, mode) =>
+ encodeAndFormatImages(req, files, params, dependencies, mode),
};
diff --git a/api/server/services/Files/images/encode.spec.js b/api/server/services/Files/images/encode.spec.js
index 262a55be458..525a38421fc 100644
--- a/api/server/services/Files/images/encode.spec.js
+++ b/api/server/services/Files/images/encode.spec.js
@@ -1,164 +1,60 @@
-const { Readable } = require('stream');
-
-const mockRunGuardedEncode = jest.fn((_bytes, task) => task());
-
-jest.mock('axios');
-jest.mock('@librechat/api', () => ({
- logAxiosError: jest.fn(({ message }) => message),
- validateImage: jest.fn().mockResolvedValue({ isValid: true }),
- runGuardedEncode: (...args) => mockRunGuardedEncode(...args),
-}));
-jest.mock('@librechat/data-schemas', () => ({
- logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
-}));
-
-const mockPrepareImagePayload = jest.fn();
-const mockGetDownloadStream = jest.fn();
-jest.mock('~/server/services/Files/strategies', () => ({
- getStrategyFunctions: jest.fn(() => ({
- prepareImagePayload: mockPrepareImagePayload,
- getDownloadStream: mockGetDownloadStream,
- })),
-}));
+jest.mock('@librechat/api', () => ({ encodeAndFormatImages: jest.fn() }));
+jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn() }));
const axios = require('axios');
-const { FileSources } = require('librechat-data-provider');
+const { Readable } = require('node:stream');
+const { encodeAndFormatImages } = require('@librechat/api');
+const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { encodeAndFormat } = require('./encode');
-const makeReq = () => ({ body: {}, config: {} });
-
-beforeEach(() => {
- jest.clearAllMocks();
- mockRunGuardedEncode.mockImplementation((_bytes, task) => task());
+beforeEach(() => jest.resetAllMocks());
+
+it('wires the existing callers to the typed image encoder without interpreting its result', () => {
+ const req = { body: {}, config: {} };
+ const files = [];
+ const params = { endpoint: 'openai', imageDetail: 'high' };
+ const result = Promise.resolve({ files: [], image_urls: [] });
+ encodeAndFormatImages.mockReturnValue(result);
+
+ expect(encodeAndFormat(req, files, params, 'agents')).toBe(result);
+ expect(encodeAndFormatImages).toHaveBeenCalledWith(
+ req,
+ files,
+ params,
+ { getStrategyFunctions, httpClient: axios },
+ 'agents',
+ );
});
-describe('encodeAndFormat - request memory guard', () => {
- it('gates blob-storage byte pulls and returns [file, base64]', async () => {
- mockGetDownloadStream.mockResolvedValue(Readable.from([Buffer.from('blob-image-bytes')]));
- const file = {
- source: FileSources.s3,
- height: 10,
- width: 10,
- type: 'image/png',
- file_id: 'f-blob',
- filepath: 'bucket/a.png',
- filename: 'a.png',
- bytes: 4321,
- };
-
- const result = await encodeAndFormat(makeReq(), [file], { endpoint: 'openai' });
-
- expect(mockRunGuardedEncode).toHaveBeenCalledTimes(1);
- expect(mockRunGuardedEncode.mock.calls[0][0]).toBe(4321);
-
- const expectedBase64 = Buffer.from('blob-image-bytes').toString('base64');
- expect(result.image_urls).toHaveLength(1);
- expect(result.image_urls[0].image_url.url).toBe(`data:image/png;base64,${expectedBase64}`);
- });
-
- it('gates base64Only URL fetches and returns [file, base64]', async () => {
- mockPrepareImagePayload.mockResolvedValue([
- { source: FileSources.vectordb, type: 'image/png' },
- 'https://images.example/x.png',
- ]);
- axios.get.mockResolvedValue({ data: Buffer.from('url-image-bytes') });
-
- const file = {
- source: FileSources.vectordb,
- height: 10,
- width: 10,
- type: 'image/png',
- file_id: 'f-url',
- filepath: 'remote/x.png',
- filename: 'x.png',
- bytes: 9876,
- };
-
- const result = await encodeAndFormat(makeReq(), [file], { endpoint: 'anthropic' });
-
- expect(mockRunGuardedEncode).toHaveBeenCalledTimes(1);
- expect(mockRunGuardedEncode.mock.calls[0][0]).toBe(9876);
-
- const expectedBase64 = Buffer.from('url-image-bytes').toString('base64');
- expect(result.image_urls).toHaveLength(1);
- expect(result.image_urls[0].source.data).toBe(expectedBase64);
- });
-
- it('does not gate the non-buffering local prepare path', async () => {
- const localBase64 = Buffer.from('local-image').toString('base64');
- mockPrepareImagePayload.mockResolvedValue([
- { source: FileSources.local, type: 'image/png' },
- localBase64,
- ]);
-
- const file = {
- source: FileSources.local,
- height: 10,
- width: 10,
- type: 'image/png',
- file_id: 'f-local',
- filepath: 'local/p.png',
- filename: 'p.png',
- bytes: 555,
- };
-
- const result = await encodeAndFormat(makeReq(), [file], { endpoint: 'openai' });
-
- expect(mockRunGuardedEncode).not.toHaveBeenCalled();
- expect(result.image_urls).toHaveLength(1);
- expect(result.image_urls[0].image_url.url).toBe(`data:image/png;base64,${localBase64}`);
- });
-});
-
-describe('encodeAndFormat - image detail', () => {
- const imageFile = () => ({
- source: FileSources.s3,
+it('encodes canonical storage keys through the built package and the existing CJS entry point', async () => {
+ encodeAndFormatImages.mockImplementation(
+ jest.requireActual('@librechat/api').encodeAndFormatImages,
+ );
+ const bytes = Buffer.from('stored-image-bytes');
+ const getDownloadStream = jest.fn().mockResolvedValue(Readable.from(bytes));
+ const prepareImagePayload = jest.fn();
+ getStrategyFunctions.mockReturnValue({ getDownloadStream, prepareImagePayload });
+ const req = { body: {}, config: {} };
+ const file = {
+ source: 's3',
+ file_id: 'image-1',
+ filename: 'image.png',
+ filepath: 'https://storage.example/image.png?signature=secret',
+ storageKey: 'images/user/image.png',
+ type: 'image/png',
+ bytes: bytes.length,
height: 10,
width: 10,
- type: 'image/png',
- file_id: 'f-detail',
- filepath: 'bucket/a.png',
- filename: 'a.png',
- bytes: 128,
- });
-
- beforeEach(() => {
- mockGetDownloadStream.mockResolvedValue(Readable.from([Buffer.from('image-bytes')]));
- });
-
- it('falls back to auto when no detail is configured anywhere', async () => {
- const result = await encodeAndFormat(makeReq(), [imageFile()], { endpoint: 'openai' });
-
- expect(result.image_urls[0].image_url.detail).toBe('auto');
- });
-
- it('uses the conversation-level detail from the request body', async () => {
- const req = makeReq();
- req.body.imageDetail = 'low';
-
- const result = await encodeAndFormat(req, [imageFile()], { endpoint: 'openai' });
-
- expect(result.image_urls[0].image_url.detail).toBe('low');
- });
-
- it('uses the detail resolved by the caller on routes with no body setting', async () => {
- const result = await encodeAndFormat(makeReq(), [imageFile()], {
- endpoint: 'agents',
- imageDetail: 'high',
- });
-
- expect(result.image_urls[0].image_url.detail).toBe('high');
- });
-
- it('prefers the caller-resolved detail over the request body', async () => {
- const req = makeReq();
- req.body.imageDetail = 'low';
-
- const result = await encodeAndFormat(req, [imageFile()], {
- endpoint: 'agents',
- imageDetail: 'high',
- });
-
- expect(result.image_urls[0].image_url.detail).toBe('high');
- });
+ };
+
+ const result = await encodeAndFormat(req, [file], { endpoint: 'openai' });
+
+ expect(getDownloadStream).toHaveBeenCalledWith(req, file.storageKey);
+ expect(prepareImagePayload).not.toHaveBeenCalled();
+ expect(result.image_urls).toEqual([
+ {
+ type: 'image_url',
+ image_url: { url: `data:image/png;base64,${bytes.toString('base64')}`, detail: 'auto' },
+ },
+ ]);
});
diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js
index 41533ee0f25..98a19a9c711 100644
--- a/api/server/services/PermissionService.js
+++ b/api/server/services/PermissionService.js
@@ -1,5 +1,5 @@
const mongoose = require('mongoose');
-const { AccessControlService, isEnabled } = require('@librechat/api');
+const { AccessControlService, isEnabled, ensureDirectoryPrincipalUser } = require('@librechat/api');
const {
tenantStorage,
getTenantId,
@@ -337,36 +337,20 @@ const ensurePrincipalExists = async function (principal) {
}
if (principal.type === PrincipalType.USER && principal.source === 'entra') {
- if (!principal.email || !principal.idOnTheSource) {
- throw new Error('Entra ID user principals must have email and idOnTheSource');
- }
-
- let existingUser = await db.findUser({ idOnTheSource: principal.idOnTheSource });
-
- if (!existingUser) {
- existingUser = await db.findUser({ email: principal.email });
- }
-
- if (existingUser) {
- if (!existingUser.idOnTheSource && principal.idOnTheSource) {
- await db.updateUser(existingUser._id, {
- idOnTheSource: principal.idOnTheSource,
- provider: 'openid',
- });
- }
- return existingUser._id.toString();
- }
-
- const userData = {
- name: principal.name,
- email: principal.email.toLowerCase(),
- emailVerified: false,
- provider: 'openid',
- idOnTheSource: principal.idOnTheSource,
- };
-
- const userId = await db.createUser(userData, true, true);
- return userId.toString();
+ return ensureDirectoryPrincipalUser(principal, {
+ findUserBySourceId: async (idOnTheSource) => {
+ const user = await db.findUser({ idOnTheSource });
+ return user ? { id: user._id.toString() } : null;
+ },
+ findUserByEmail: async (email) => {
+ const user = await db.findUser({ email });
+ return user ? { id: user._id.toString() } : null;
+ },
+ createUser: async (userData) => {
+ const userId = await db.createUser(userData, true, true);
+ return userId.toString();
+ },
+ });
}
if (principal.type === PrincipalType.GROUP) {
diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js
index 65680249d7c..a2dae303669 100644
--- a/api/server/services/PermissionService.spec.js
+++ b/api/server/services/PermissionService.spec.js
@@ -296,6 +296,46 @@ describe('PermissionService', () => {
expect(principalId).toBe(currentUser._id.toString());
});
+ test('accepts a directory user already linked to the supplied source id', async () => {
+ const directoryUser = await User.create({
+ name: 'ACL Principal Directory User',
+ email: 'acl-principal-directory-user@example.com',
+ provider: 'openid',
+ idOnTheSource: 'directory-user-id',
+ });
+
+ const principalId = await ensurePrincipalExists({
+ type: PrincipalType.USER,
+ name: directoryUser.name,
+ email: directoryUser.email,
+ source: 'entra',
+ idOnTheSource: directoryUser.idOnTheSource,
+ });
+
+ expect(principalId).toBe(directoryUser._id.toString());
+ });
+
+ test('uses an existing user found only by email without linking its identity', async () => {
+ const existingUser = await User.create({
+ name: 'ACL Principal Existing User',
+ email: 'acl-principal-existing-user@example.com',
+ provider: 'local',
+ });
+
+ const principalId = await ensurePrincipalExists({
+ type: PrincipalType.USER,
+ name: existingUser.name,
+ email: existingUser.email,
+ source: 'entra',
+ idOnTheSource: 'unlinked-directory-id',
+ });
+
+ const unchangedUser = await User.findById(existingUser._id).lean();
+ expect(principalId).toBe(existingUser._id.toString());
+ expect(unchangedUser.provider).toBe('local');
+ expect(unchangedUser.idOnTheSource).toBeUndefined();
+ });
+
test('rejects a local group id outside the current request context', async () => {
const outsideGroup = await Group.create({
name: 'ACL Principal Outside Group',
diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js
index bc7693cf34a..4952ee774c9 100644
--- a/api/server/services/ToolService.js
+++ b/api/server/services/ToolService.js
@@ -52,6 +52,7 @@ const {
createGitIdentityProgrammaticBashTool,
resolveCodeExecutionContext,
resolveCodeExecutionWorkspaceContext,
+ resolveRunFileCodeExecutionContext,
resolveCallerCapabilityProjectionSnapshot,
CREATE_FILE_TOOL_NAME,
EDIT_FILE_TOOL_NAME,
@@ -150,6 +151,7 @@ const getActiveToolResources = (toolResources, tools) => {
const toolCapabilityGates = {
[Tools.file_search]: AgentCapabilities.file_search,
[Tools.execute_code]: AgentCapabilities.execute_code,
+ [Tools.web_search]: AgentCapabilities.web_search,
};
/**
@@ -864,7 +866,7 @@ async function loadToolDefinitionsWrapper({
return checkCapability(AgentCapabilities.execute_code) && canUseTool(tool);
}
if (tool === Tools.web_search) {
- return checkCapability(AgentCapabilities.web_search);
+ return checkCapability(AgentCapabilities.web_search) && canUseTool(tool);
}
if (tool === Tools.memory) {
return checkCapability(AgentCapabilities.memory);
@@ -1650,7 +1652,7 @@ async function loadAgentTools({
} else if (tool === Tools.execute_code) {
return checkCapability(AgentCapabilities.execute_code) && canUseTool(tool);
} else if (tool === Tools.web_search) {
- includesWebSearch = checkCapability(AgentCapabilities.web_search);
+ includesWebSearch = checkCapability(AgentCapabilities.web_search) && canUseTool(tool);
return includesWebSearch;
} else if (tool === Tools.memory) {
return checkCapability(AgentCapabilities.memory);
@@ -2050,6 +2052,7 @@ async function loadToolsForExecution({
conversationId,
actionsEnabled,
accessibleMcpServerNames,
+ runFileCodeExecutionContext,
}) {
const appConfig = req.config;
const allLoadedTools = [];
@@ -2148,6 +2151,10 @@ async function loadToolsForExecution({
environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments,
getAppConfig,
});
+ Object.assign(
+ codeExecutionContext,
+ resolveRunFileCodeExecutionContext(codeExecutionContext, runFileCodeExecutionContext),
+ );
configurable.codeExecutionContext = codeExecutionContext;
const isPTC =
diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js
index 0070b65c0a1..52dd1a362ba 100644
--- a/api/server/services/Tools/mcp.js
+++ b/api/server/services/Tools/mcp.js
@@ -4,6 +4,7 @@ const {
getUserMCPAuthMap,
getMissingCustomUserVars,
loadMCPServerCatalogs: loadCatalogs,
+ resolveMCPReinitializeConfig,
requiresEphemeralUserConnection,
getMissingRuntimeBodyPlaceholderFields,
MCPAuthenticationRejectedError,
@@ -37,7 +38,6 @@ const {
} = require('~/server/services/MCPAuthorizationFenceRetry');
const MCP_REINITIALIZE_FAILURE_REASONS = {
- UNREACHABLE: 'unreachable',
MISSING_CUSTOM_USER_VARS: 'missing_custom_user_vars',
OAUTH_REQUIRED: 'oauth_required',
INITIALIZATION_FAILED: 'initialization_failed',
@@ -73,8 +73,8 @@ async function loadMCPServerCatalogs({
invalidateRecoveryGeneration: invalidateCachedTools,
persistPublicationRetry: persistMCPAuthorizationFenceRetry,
clearPublicationRetry: clearMCPAuthorizationFenceRetry,
- clearLocalRecovery: (userId, serverName) =>
- mcpManager.clearCatalogRecoveryState?.(userId, serverName),
+ clearLocalRecovery: (userId, serverName, generation) =>
+ mcpManager.clearCatalogRecoveryState?.(userId, serverName, generation),
retryDelaysMs: recoveryPolicy?.authorizationFenceRetryMs,
attemptTimeoutMs: recoveryPolicy?.authorizationFenceTimeoutMs,
});
@@ -97,8 +97,8 @@ async function loadMCPServerCatalogs({
oboTrustChecker: createOboTrustChecker(),
upstreamTokenProvider,
oboIdentityContext,
- onOAuthCredentialsChanging,
}),
+ onOAuthCredentialsChanging,
formatServerTools: formatMCPServerTools,
recoveryTracker: mcpManager.getCatalogRecoveryTracker?.(),
getRecoveryGeneration: getMCPToolsCacheGeneration,
@@ -166,45 +166,17 @@ async function reinitMCPServer({
try {
const registry = getMCPServersRegistry();
- serverConfig =
- serverConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers));
- ephemeralServer = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false;
- if (serverConfig?.inspectionFailed) {
- if (serverConfig.source === 'config') {
- logger.info(
- '[MCP Reinitialize] Config-source server inspection failed; retry handled by config cache',
- );
- return {
- availableTools: null,
- success: false,
- message: `MCP server '${serverName}' is still unreachable`,
- failureReason: MCP_REINITIALIZE_FAILURE_REASONS.UNREACHABLE,
- oauthRequired: false,
- serverName,
- oauthUrl: null,
- tools: null,
- };
- } else {
- logger.info('[MCP Reinitialize] Server inspection failed; attempting reinspection');
- try {
- const storageLocation = serverConfig.source === 'user' ? 'DB' : 'CACHE';
- await registry.reinspectServer(serverName, storageLocation, user?.id);
- logger.info('[MCP Reinitialize] Server reinspection succeeded');
- } catch {
- logger.error('[MCP Reinitialize] Server reinspection failed');
- return {
- availableTools: null,
- success: false,
- message: `MCP server '${serverName}' is still unreachable`,
- failureReason: MCP_REINITIALIZE_FAILURE_REASONS.UNREACHABLE,
- oauthRequired: false,
- serverName,
- oauthUrl: null,
- tools: null,
- };
- }
- }
+ const resolution = await resolveMCPReinitializeConfig(
+ registry,
+ serverName,
+ serverConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers)),
+ user?.id,
+ );
+ if (resolution.result) {
+ return resolution.result;
}
+ serverConfig = resolution.serverConfig;
+ ephemeralServer = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false;
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js
index e0f54659d77..dfd222f25f7 100644
--- a/api/server/services/Tools/mcp.spec.js
+++ b/api/server/services/Tools/mcp.spec.js
@@ -13,6 +13,8 @@ const mockFormatMCPServerTools = jest.fn();
const mockGetMCPServerTools = jest.fn();
const mockCacheMCPServerTools = jest.fn();
const mockGetServerToolFunctionsSnapshot = jest.fn();
+const mockClearCatalogRecoveryState = jest.fn();
+const mockInvalidateCachedTools = jest.fn();
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
@@ -27,6 +29,7 @@ jest.mock('~/config', () => ({
discoverServerTools: mockDiscoverServerTools,
getServerToolFunctionsSnapshot: mockGetServerToolFunctionsSnapshot,
getToolPublicationGeneration: mockGetToolPublicationGeneration,
+ clearCatalogRecoveryState: mockClearCatalogRecoveryState,
})),
getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })),
getFlowStateManager: jest.fn(() => ({})),
@@ -43,6 +46,11 @@ jest.mock('~/server/services/Config', () => ({
getMCPToolsCacheGeneration: mockGetMCPToolsCacheGeneration,
getMCPServerTools: mockGetMCPServerTools,
cacheMCPServerTools: mockCacheMCPServerTools,
+ invalidateCachedTools: mockInvalidateCachedTools,
+}));
+jest.mock('~/server/services/MCPAuthorizationFenceRetry', () => ({
+ persistMCPAuthorizationFenceRetry: jest.fn().mockResolvedValue('retry-v1'),
+ clearMCPAuthorizationFenceRetry: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/server/services/GraphTokenService', () => ({
getGraphApiToken: mockGetGraphApiToken,
@@ -73,7 +81,10 @@ describe('loadMCPServerCatalogs', () => {
mockGetUserMCPAuthMap.mockResolvedValue({});
mockDiscoverServerTools.mockResolvedValue({ tools: [] });
mockFormatMCPServerTools.mockReturnValue({});
+ const observedCredentialFence = jest.fn();
+ let recoveryDeps;
mockLoadCatalogs.mockImplementation(async (params, deps) => {
+ recoveryDeps = deps;
await deps.loadUserMCPAuthMap(
user.id,
servers.map(({ serverName }) => serverName),
@@ -82,6 +93,7 @@ describe('loadMCPServerCatalogs', () => {
user,
serverName: 'config-only',
configServers: { 'config-only': servers[0].serverConfig },
+ onOAuthCredentialsChanging: observedCredentialFence,
});
deps.formatServerTools('config-only', []);
await deps.getCachedServerTools(user.id, 'config-only', servers[0].serverConfig);
@@ -112,6 +124,7 @@ describe('loadMCPServerCatalogs', () => {
servers: ['config-only', 'user-server'],
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
});
+ expect(recoveryDeps.onOAuthCredentialsChanging).toEqual(expect.any(Function));
expect(mockDiscoverServerTools).toHaveBeenCalledWith(
expect.objectContaining({
user,
@@ -121,6 +134,7 @@ describe('loadMCPServerCatalogs', () => {
tokenMethods: expect.any(Object),
upstreamTokenProvider,
oboIdentityContext,
+ onOAuthCredentialsChanging: observedCredentialFence,
}),
);
expect(mockGetConnection).not.toHaveBeenCalled();
@@ -141,6 +155,28 @@ describe('loadMCPServerCatalogs', () => {
serversWithoutTools: [],
});
});
+
+ it('clears catalog recovery with the generation its credential fence published', async () => {
+ let recoveryDeps;
+ mockInvalidateCachedTools.mockResolvedValue('generation-2');
+ mockLoadCatalogs.mockImplementation(async (params, deps) => {
+ recoveryDeps = deps;
+ return { serverTools: new Map(), serversWithoutTools: [] };
+ });
+
+ await loadMCPServerCatalogs({ user: { id: 'user-123' }, servers: [] });
+ const publish = await recoveryDeps.onOAuthCredentialsChanging({
+ userId: 'user-123',
+ serverName: 'oauth-server',
+ });
+
+ await expect(publish()).resolves.toBe('generation-2');
+ expect(mockClearCatalogRecoveryState).toHaveBeenCalledWith(
+ 'user-123',
+ 'oauth-server',
+ 'generation-2',
+ );
+ });
});
describe('reinitMCPServer โ customUserVars gating (issue #10969)', () => {
@@ -445,6 +481,58 @@ describe('reinitMCPServer โ customUserVars gating (issue #10969)', () => {
});
});
+describe('reinitMCPServer โ recovery of a server that failed inspection', () => {
+ const user = { id: 'user-123' };
+ const serverName = 'Recovering';
+ const stub = {
+ type: 'streamable-http',
+ url: 'https://recovering.example.com/mcp',
+ source: 'yaml',
+ inspectionFailed: true,
+ };
+ const { getMCPServersRegistry } = require('~/config');
+
+ beforeEach(() => {
+ mockUpdateMCPServerTools.mockResolvedValue({});
+ });
+
+ it('connects with the recovered config instead of the stub it read', async () => {
+ const recovered = {
+ type: 'streamable-http',
+ url: 'https://recovering.example.com/mcp',
+ source: 'yaml',
+ requiresOAuth: false,
+ };
+ const recoverServerConfig = jest.fn().mockResolvedValue(recovered);
+ getMCPServersRegistry.mockReturnValueOnce({ recoverServerConfig });
+ mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) });
+
+ const result = await reinitMCPServer({ user, serverName, serverConfig: stub });
+
+ expect(recoverServerConfig).toHaveBeenCalledWith(serverName, stub, user.id);
+ expect(mockGetConnection).toHaveBeenCalledWith(
+ expect.objectContaining({ serverName, serverConfig: recovered }),
+ );
+ expect(result).toMatchObject({ success: true, serverName });
+ });
+
+ it('reports the server unreachable without connecting while it cannot be recovered', async () => {
+ const recoverServerConfig = jest.fn().mockResolvedValue(undefined);
+ getMCPServersRegistry.mockReturnValueOnce({ recoverServerConfig });
+
+ const result = await reinitMCPServer({ user, serverName, serverConfig: stub });
+
+ expect(mockGetConnection).not.toHaveBeenCalled();
+ expect(result).toMatchObject({
+ availableTools: null,
+ success: false,
+ message: `MCP server '${serverName}' is still unreachable`,
+ failureReason: 'unreachable',
+ tools: null,
+ });
+ });
+});
+
describe('reinitMCPServer โ direct bearer authentication outcomes', () => {
it('preserves a typed rejection instead of reducing it to a generic result', async () => {
const { MCPAuthenticationRejectedError } = require('@librechat/api');
diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js
index 21d5540790e..1c15094b4e3 100644
--- a/api/server/services/__tests__/ToolService.spec.js
+++ b/api/server/services/__tests__/ToolService.spec.js
@@ -202,13 +202,14 @@ const { createOnSearchResults } = require('~/server/services/Tools/search');
const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { ContentFilterError, PENDING_STALE_MS } = require('@librechat/api');
-/** Role document shape `checkAccess` reads; both role-gated tools granted. */
+/** Role document shape `checkAccess` reads; all three role-gated tools granted. */
function buildRole(overrides = {}) {
return {
name: 'USER',
permissions: {
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
+ [PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
...overrides,
},
};
@@ -3873,6 +3874,7 @@ describe('ToolService - Action Capability Gating', () => {
AgentCapabilities.tools,
AgentCapabilities.file_search,
AgentCapabilities.execute_code,
+ AgentCapabilities.web_search,
];
const denyPermission = (deniedType) =>
@@ -3980,6 +3982,61 @@ describe('ToolService - Action Capability Gating', () => {
expect(callArgs.codeExecutionEnabled).toBe(true);
});
+ it('omits web_search from definitions when WEB_SEARCH.USE is denied', async () => {
+ denyPermission(PermissionTypes.WEB_SEARCH);
+
+ await loadAgentTools({
+ req: createMockReq(capabilities),
+ res: {},
+ agent: { id: 'agent_123', tools: [Tools.web_search, Tools.execute_code] },
+ definitionsOnly: true,
+ });
+
+ const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
+ expect(callArgs.tools).not.toContain(Tools.web_search);
+ expect(callArgs.tools).toContain(Tools.execute_code);
+ });
+
+ it('omits web_search from the runtime loader when WEB_SEARCH.USE is denied', async () => {
+ denyPermission(PermissionTypes.WEB_SEARCH);
+
+ await loadAgentTools({
+ req: createMockReq(capabilities),
+ res: {},
+ agent: { id: 'agent_123', tools: [Tools.web_search, Tools.execute_code] },
+ definitionsOnly: false,
+ });
+
+ expect(mockLoadToolsUtil).toHaveBeenCalledTimes(1);
+ const [callArgs] = mockLoadToolsUtil.mock.calls[0];
+ expect(callArgs.tools).not.toContain(Tools.web_search);
+ expect(callArgs.tools).toContain(Tools.execute_code);
+ });
+
+ it('keeps web_search when the role grants it', async () => {
+ await loadAgentTools({
+ req: createMockReq(capabilities),
+ res: {},
+ agent: { id: 'agent_123', tools: [Tools.web_search] },
+ definitionsOnly: true,
+ });
+
+ const [callArgs] = mockLoadToolDefinitions.mock.calls[0];
+ expect(callArgs.tools).toContain(Tools.web_search);
+ });
+
+ it('keeps web_search in the runtime loader when the role grants it', async () => {
+ await loadAgentTools({
+ req: createMockReq(capabilities),
+ res: {},
+ agent: { id: 'agent_123', tools: [Tools.web_search] },
+ definitionsOnly: false,
+ });
+
+ const [callArgs] = mockLoadToolsUtil.mock.calls[0];
+ expect(callArgs.tools).toContain(Tools.web_search);
+ });
+
it('fails closed when the role lookup throws', async () => {
mockGetRoleByName.mockRejectedValue(new Error('role lookup failed'));
diff --git a/api/server/services/initializeMCPs.js b/api/server/services/initializeMCPs.js
index 4796f38e952..cd18896f89b 100644
--- a/api/server/services/initializeMCPs.js
+++ b/api/server/services/initializeMCPs.js
@@ -116,6 +116,8 @@ async function initializeMCPs() {
try {
const mcpManager = await createMCPManager(mcpServers || {}, {
catalogRecoveryMaxStateEntries: appConfig?.mcpSettings?.catalogRecovery?.maxStateEntries,
+ catalogRecoveryMaxDetachedDiscoveries:
+ appConfig?.mcpSettings?.catalogRecovery?.maxDetachedDiscoveries,
});
startMCPAuthorizationFenceRetryWorker(invalidateCachedTools, {
intervalMs: appConfig?.mcpSettings?.catalogRecovery?.authorizationFenceRetryIntervalMs,
diff --git a/api/server/services/initializeMCPs.spec.js b/api/server/services/initializeMCPs.spec.js
index d1b17bff2d9..0f1f9250e24 100644
--- a/api/server/services/initializeMCPs.spec.js
+++ b/api/server/services/initializeMCPs.spec.js
@@ -239,6 +239,7 @@ describe('initializeMCPs', () => {
{},
{
catalogRecoveryMaxStateEntries: undefined,
+ catalogRecoveryMaxDetachedDiscoveries: undefined,
},
);
});
@@ -254,6 +255,7 @@ describe('initializeMCPs', () => {
expect(mockCreateMCPManager).toHaveBeenCalledWith(mcpServers, {
catalogRecoveryMaxStateEntries: undefined,
+ catalogRecoveryMaxDetachedDiscoveries: undefined,
});
});
@@ -263,6 +265,7 @@ describe('initializeMCPs', () => {
mcpSettings: {
catalogRecovery: {
maxStateEntries: 2500,
+ maxDetachedDiscoveries: 8,
authorizationFenceRetryIntervalMs: 15_000,
authorizationFenceRetryBatchSize: 250,
authorizationFenceTimeoutMs: 750,
@@ -276,6 +279,7 @@ describe('initializeMCPs', () => {
{},
{
catalogRecoveryMaxStateEntries: 2500,
+ catalogRecoveryMaxDetachedDiscoveries: 8,
},
);
expect(mockStartMCPAuthorizationFenceRetryWorker).toHaveBeenCalledWith(
@@ -465,6 +469,7 @@ describe('initializeMCPs', () => {
{},
{
catalogRecoveryMaxStateEntries: undefined,
+ catalogRecoveryMaxDetachedDiscoveries: undefined,
},
);
});
diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js
index cd08d51862e..467aabc0dea 100644
--- a/api/server/utils/import/fork.js
+++ b/api/server/utils/import/fork.js
@@ -1,4 +1,5 @@
const { v4: uuidv4 } = require('uuid');
+const { withoutTraceRefs } = require('@librechat/api');
const { logger, tenantStorage } = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider');
const { getConvo, getMessages, getSharedMessages } = require('~/models');
@@ -64,7 +65,7 @@ function cloneMessagesWithTimestamps(
}
const clonedMessage = {
- ...message,
+ ...withoutTraceRefs(message),
messageId: newMessageId,
parentMessageId: parentId,
createdAt,
diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js
index 10c2bfdfbf6..d53bb8775c1 100644
--- a/api/server/utils/import/fork.spec.js
+++ b/api/server/utils/import/fork.spec.js
@@ -185,6 +185,44 @@ describe('forkConversation', () => {
).toBe(true);
});
+ test('does not carry the source messages trace sampling onto their copies', async () => {
+ getMessages.mockResolvedValue([
+ {
+ messageId: 'user-1',
+ parentMessageId: Constants.NO_PARENT,
+ isCreatedByUser: true,
+ text: 'Hello',
+ },
+ {
+ messageId: 'response-1',
+ parentMessageId: 'user-1',
+ isCreatedByUser: false,
+ text: 'Hi',
+ langfuseSampled: true,
+ langfuseDestinationIds: ['destination-a'],
+ langfuseRunId: 'run-a',
+ },
+ ]);
+
+ await forkConversation({
+ originalConvoId: 'abc123',
+ targetMessageId: 'response-1',
+ requestUserId: 'user1',
+ option: ForkOptions.DIRECT_PATH,
+ });
+
+ const savedMessages = bulkSaveMessages.mock.calls[0][0];
+ expect(savedMessages.map((message) => message.text)).toEqual(['Hello', 'Hi']);
+ expect(
+ savedMessages.every(
+ (message) =>
+ message.langfuseSampled === false &&
+ !('langfuseDestinationIds' in message) &&
+ !('langfuseRunId' in message),
+ ),
+ ).toBe(true);
+ });
+
test('drops child execution metadata while retaining its visible transcript', async () => {
getConvo.mockResolvedValue({
...mockConversation,
diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js
index 181796c3398..d1d2edd29df 100644
--- a/api/server/utils/import/importers.js
+++ b/api/server/utils/import/importers.js
@@ -7,6 +7,7 @@ const {
stripMessageUIResourceMarkers,
} = require('@librechat/data-schemas');
const { EModelEndpoint, Constants, Tools, openAISettings } = require('librechat-data-provider');
+const { withoutTraceRefs } = require('@librechat/api');
const { getEndpointsConfig } = require('~/server/services/Config');
const { createImportBatchBuilder } = require('./importBatchBuilder');
const { resolveImportDefaultModel } = require('./defaults');
@@ -59,8 +60,8 @@ function sanitizeImportedMessage(message) {
const text = castPersistedImportedText(message.text);
const content = normalizeImportedArray(message.content);
const attachments = normalizeImportedArray(message.attachments);
- const importable = { ...message };
- /** Server-private run state never comes from an import. */
+ /** Server-private run state and trace sampling records never come from an import. */
+ const importable = withoutTraceRefs({ ...message });
delete importable.contextMeta;
return {
...importable,
diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js
index fcc96162fe9..aa71772de5f 100644
--- a/api/server/utils/import/importers.spec.js
+++ b/api/server/utils/import/importers.spec.js
@@ -966,12 +966,15 @@ describe('importLibreChatConvo', () => {
]);
});
- it('drops server-private context meta from imported messages', async () => {
+ it('drops server-private context meta and trace sampling from imported messages', async () => {
const message = {
messageId: 'message-1',
parentMessageId: Constants.NO_PARENT,
text: 'Imported response',
isCreatedByUser: false,
+ langfuseSampled: true,
+ langfuseDestinationIds: ['forged-destination'],
+ langfuseRunId: 'someone-elses-run',
contextMeta: {
calibrationRatio: 1,
encoding: 'claude',
@@ -990,6 +993,9 @@ describe('importLibreChatConvo', () => {
await importer(jsonData, 'user-123', () => importBatchBuilder);
expect(importBatchBuilder.messages[0]).not.toHaveProperty('contextMeta');
+ expect(importBatchBuilder.messages[0].langfuseSampled).toBe(false);
+ expect(importBatchBuilder.messages[0]).not.toHaveProperty('langfuseDestinationIds');
+ expect(importBatchBuilder.messages[0]).not.toHaveProperty('langfuseRunId');
expect(importBatchBuilder.messages[0].isUserSubmitted).toBe(true);
});
diff --git a/bun.lock b/bun.lock
index c024ae78123..56f5d7c8a43 100644
--- a/bun.lock
+++ b/bun.lock
@@ -39,7 +39,7 @@
},
"api": {
"name": "@librechat/backend",
- "version": "0.8.8-rc2",
+ "version": "0.8.8-rc3",
"dependencies": {
"@anthropic-ai/vertex-sdk": "^0.16.0",
"@aws-sdk/client-bedrock-runtime": "^3.1013.0",
@@ -151,7 +151,7 @@
},
"client": {
"name": "@librechat/frontend",
- "version": "0.8.8-rc2",
+ "version": "0.8.8-rc3",
"dependencies": {
"@ariakit/react": "^0.4.29",
"@ariakit/react-components": "^0.1.2",
@@ -294,7 +294,7 @@
},
"packages/api": {
"name": "@librechat/api",
- "version": "1.7.47",
+ "version": "1.7.48",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.1.2",
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
@@ -407,7 +407,7 @@
},
"packages/client": {
"name": "@librechat/client",
- "version": "0.4.77",
+ "version": "0.4.78",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.29.5",
@@ -490,7 +490,7 @@
},
"packages/data-provider": {
"name": "librechat-data-provider",
- "version": "0.8.522",
+ "version": "0.8.523",
"dependencies": {
"axios": "^1.16.0",
"dayjs": "^1.11.13",
@@ -525,7 +525,7 @@
},
"packages/data-schemas": {
"name": "@librechat/data-schemas",
- "version": "0.0.69",
+ "version": "0.0.70",
"dependencies": {
"mdast-util-directive": "^3.0.0",
"mdast-util-from-markdown": "^2.0.1",
diff --git a/client/jest.config.cjs b/client/jest.config.cjs
index 7a32adf945e..8c01907f403 100644
--- a/client/jest.config.cjs
+++ b/client/jest.config.cjs
@@ -1,4 +1,4 @@
-/** v0.8.8-rc2 */
+/** v0.8.8-rc3 */
module.exports = {
roots: ['/src'],
testEnvironment: 'jsdom',
diff --git a/client/package.json b/client/package.json
index a8f070957c9..0072b1971cf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
- "version": "v0.8.8-rc2",
+ "version": "v0.8.8-rc3",
"description": "",
"type": "module",
"scripts": {
diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts
index 7bad08b8196..5cbaa759410 100644
--- a/client/src/common/agents-types.ts
+++ b/client/src/common/agents-types.ts
@@ -52,6 +52,7 @@ export type AgentForm = {
stateful_code_environment?: StatefulCodeEnvironment;
/** Operator-configured managed or attached execution environment. */
code_environment_id?: string | null;
+ code_workspace_id?: string;
/** Git authorship applied to sandboxed commands for this agent. */
git_identity?: Agent['git_identity'];
provider?: AgentProvider | OptionWithIcon;
diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx
index de800d6eb4d..24186fa708f 100644
--- a/client/src/components/Chat/ChatView.tsx
+++ b/client/src/components/Chat/ChatView.tsx
@@ -26,6 +26,7 @@ import { AskAnswerHostProvider } from './ask/state';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
+import { TraceSurface } from './Trace';
import Landing from './Landing';
import Header from './Header';
import { cn } from '~/utils';
@@ -156,7 +157,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
-
+
{pageHeading}
+
diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx
index 02ce42ca2b9..240e83fa3fa 100644
--- a/client/src/components/Chat/Header.tsx
+++ b/client/src/components/Chat/Header.tsx
@@ -10,6 +10,7 @@ import {
import { OpenSidebar, PresetsMenu, NewChat, HeaderMenu } from './Menus';
import { TemporaryChat, TemporaryChatIndicator } from './TemporaryChat';
import ModelSelector from './Menus/Endpoints/ModelSelector';
+import { TraceButton, useTraceControl } from './Trace';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import SubagentThreadLink from './SubagentThreadLink';
@@ -36,6 +37,7 @@ function Header({
}) {
const { data: startupConfig } = useGetStartupConfig();
const navVisible = useRecoilValue(store.sidebarExpanded);
+ const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
/** The mobile row only offers a new chat when there is one to leave. Read
* from the route rather than the context conversation, which still holds the
@@ -64,6 +66,14 @@ function Header({
permission: Permissions.USE,
});
+ /** Child threads are view-only records of their parent's run and have no trace of their own. */
+ const trace = useTraceControl({
+ conversationId: isNewChat ? null : routeConversationId,
+ traceViewer: interfaceConfig.traceViewer,
+ isSubmitting,
+ enabled: parentConversationId == null,
+ });
+
/** The drawer covers the header on mobile; keep its controls out of the tab order. */
const hiddenBehindNav = navVisible === true && 'max-md:hidden';
@@ -101,8 +111,9 @@ function Header({
{hasAccessToTemporaryChat === true &&
}
{!isNewChat &&
}
-
+
+ {trace.show && }
{hasAccessToTemporaryChat === true && }
diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx
index f7e640a21af..6bc3a9715b4 100644
--- a/client/src/components/Chat/Input/ChatForm.tsx
+++ b/client/src/components/Chat/Input/ChatForm.tsx
@@ -650,6 +650,28 @@ const ChatForm = memo(function ChatForm({
onRestoreToComposer={restoreReclaimedSteer}
/>
)}
+ {(project ||
+ (codeWorkspace.required && (!codeWorkspace.locked || !codeWorkspace.canSubmit))) && (
+
+ {project ?
: null}
+ {codeWorkspace.required && (!codeWorkspace.locked || !codeWorkspace.canSubmit) ? (
+
+
+
+ ) : null}
+
+ )}
- {project ? : null}
{quotesEnabled && (
@@ -782,11 +803,6 @@ const ChatForm = memo(function ChatForm({
onFocus={handleTextareaFocus}
onBlur={handleTextareaBlur}
aria-label={localize('com_ui_message_input')}
- aria-describedby={
- codeWorkspace.state === 'choose' || codeWorkspace.state === 'missing'
- ? `code-workspace-hint-${index}`
- : undefined
- }
onClick={handleFocusOrClick}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
@@ -805,15 +821,6 @@ const ChatForm = memo(function ChatForm({
)}
- {(codeWorkspace.state === 'choose' || codeWorkspace.state === 'missing') && (
-
- {localize('com_error_code_workspace_required')}
-
- )}
-
{index === 0 && conversationId != null && (
)}
@@ -964,6 +965,7 @@ function ChatFormWrapper({
conversation?.model,
conversation?.maxContextTokens,
conversation?.codeApprovalMode,
+ conversation?.codeEnvironmentMode,
conversation?.codeWorkspaces,
hasMessages,
],
diff --git a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
index 4af4cd4abd3..e7f0560f270 100644
--- a/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
+++ b/client/src/components/Chat/Input/CodeWorkspaceMenu.tsx
@@ -1,4 +1,3 @@
-import { useEffect } from 'react';
import * as Ariakit from '@ariakit/react';
import { Check, ChevronDown, Folder, FolderX } from 'lucide-react';
import { TooltipAnchor, composerControlClasses } from '@librechat/client';
@@ -14,15 +13,14 @@ const stateLabels: Partial
missing: 'com_ui_code_workspace_missing',
unavailable: 'com_ui_code_workspace_unavailable',
unsupported: 'com_ui_code_workspace_unsupported',
+ without_attached: 'com_ui_code_workspace_without_attached',
};
export default function CodeWorkspaceMenu({
- conversation,
setConversation,
workspace,
disabled,
}: {
- conversation: TConversation | null;
setConversation: SetterOrUpdater;
workspace: CodeWorkspaceResult;
disabled: boolean;
@@ -31,42 +29,46 @@ export default function CodeWorkspaceMenu({
const menuStore = Ariakit.useMenuStore({ focusLoop: true, placement: 'top-start' });
const isOpen = menuStore.useState('open');
- /** A single advertised root per reachable environment is an unambiguous
- * initial choice. Once a conversation owns any binding, even a partial or
- * stale set, only explicit user actions may replace or complete it. */
- useEffect(() => {
- if (conversation?.codeWorkspaces != null || workspace.selections == null) return;
- setConversation((current) =>
- current == null || current.codeWorkspaces != null
- ? current
- : { ...current, codeWorkspaces: workspace.selections },
- );
- }, [conversation?.codeWorkspaces, setConversation, workspace.selections]);
-
if (!workspace.required) return null;
const environmentIds = new Set(workspace.environments.map(({ environment }) => environment.id));
const selectWorkspace = (selection: CodeWorkspaceSelection) => {
+ workspace.rememberSelection(selection);
setConversation((current) => {
if (current == null) return current;
- const retained = (current.codeWorkspaces ?? []).filter(
+ const retained = (current.codeWorkspaces ?? workspace.selections ?? []).filter(
({ environmentId }) =>
environmentIds.has(environmentId) && environmentId !== selection.environmentId,
);
return {
...current,
+ codeEnvironmentMode: 'attached',
codeWorkspaces: [...retained, selection].sort((a, b) =>
a.environmentId.localeCompare(b.environmentId),
),
};
});
};
+ const selectWithoutAttached = () => {
+ setConversation((current) =>
+ current == null
+ ? current
+ : {
+ ...current,
+ codeEnvironmentMode: 'without_attached',
+ codeWorkspaces: undefined,
+ },
+ );
+ };
const onlyEnvironment = workspace.environments.length === 1 ? workspace.environments[0] : null;
const onlyDescriptor = onlyEnvironment?.workspaces.find(
({ id }) => id === onlyEnvironment.selected?.workspaceId,
);
const labelKey = stateLabels[workspace.state];
- let label = onlyDescriptor?.name ?? onlyDescriptor?.id;
+ let label =
+ workspace.mode === 'without_attached'
+ ? localize('com_ui_code_workspace_without_attached')
+ : (onlyDescriptor?.name ?? onlyDescriptor?.id);
if (label == null && workspace.state === 'ready') {
label = localize('com_ui_code_workspaces_selected', {
0: workspace.selections?.length ?? 0,
@@ -74,9 +76,33 @@ export default function CodeWorkspaceMenu({
} else if (label == null) {
label = labelKey ? localize(labelKey) : localize('com_ui_code_workspace_choose');
}
- const canChoose = workspace.environments.some(({ workspaces }) => workspaces.length > 0);
const Icon =
- workspace.state === 'missing' || workspace.state === 'unavailable' ? FolderX : Folder;
+ workspace.mode === 'without_attached' ||
+ workspace.state === 'missing' ||
+ workspace.state === 'unavailable'
+ ? FolderX
+ : Folder;
+
+ if (workspace.locked) {
+ if (workspace.canSubmit) return null;
+ const recovery = localize('com_ui_code_workspace_locked_recovery');
+ return (
+
+ }
+ >
+
+ {label}
+
+ );
+ }
return (
@@ -85,100 +111,131 @@ export default function CodeWorkspaceMenu({
disabled={isOpen}
render={
}
>
{label}
- {canChoose && (
-
- )}
-
- {canChoose && (
-
+
+
+ }
+ className="px-2.5 py-1.5 text-xs font-medium text-text-secondary"
>
- {workspace.environments.map(({ environment, state, workspaces, selected }) => (
-
-
}
- className="px-2.5 py-1.5 text-xs font-medium text-text-secondary"
- >
- {environment.name ?? environment.id}
-
- {workspaces.length === 0 && (
-
- {localize(stateLabels[state] ?? 'com_ui_code_workspace_unavailable')}
-
- )}
- {workspaces.map((descriptor) => {
- const isSelected = descriptor.id === selected?.workspaceId;
- return (
-
- selectWorkspace({ environmentId: environment.id, workspaceId: descriptor.id })
- }
- className={cn(
- 'group flex w-full cursor-pointer items-start gap-3 rounded-lg px-2.5 py-2',
- 'outline-none transition-colors duration-theme-fast',
- 'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
- isSelected && 'bg-surface-active-alt',
+ {localize('com_ui_code_environment')}
+
+ {workspace.supportsEnvironmentDecisions && (
+
+
+
+
+ {localize('com_ui_code_workspace_without_attached')}
+
+
+ {localize('com_ui_code_workspace_without_attached_info')}
+
+
+ {workspace.mode === 'without_attached' && (
+
+ )}
+
+ )}
+ {workspace.environments.map(({ environment, state, workspaces, selected }) => (
+
+
}
+ className="px-2.5 py-1.5 text-xs font-medium text-text-secondary"
+ >
+ {environment.name ?? environment.id}
+
+ {workspaces.length === 0 && (
+
+ {localize(stateLabels[state] ?? 'com_ui_code_workspace_unavailable')}
+
+ )}
+ {workspaces.map((descriptor) => {
+ const isSelected =
+ workspace.mode === 'attached' && descriptor.id === selected?.workspaceId;
+ return (
+
+ selectWorkspace({ environmentId: environment.id, workspaceId: descriptor.id })
+ }
+ className={cn(
+ 'group flex w-full cursor-pointer items-start gap-3 rounded-lg px-2.5 py-2',
+ 'outline-none transition-colors duration-theme-fast',
+ 'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
+ isSelected && 'bg-surface-active-alt',
+ )}
+ >
+
+
+
+ {descriptor.name ?? descriptor.id}
+
+ {descriptor.name && (
+
{descriptor.id}
)}
- >
-
+ {isSelected && (
+
-
-
- {descriptor.name ?? descriptor.id}
-
- {descriptor.name && (
-
{descriptor.id}
- )}
-
- {isSelected && (
-
- )}
-
- );
- })}
-
- ))}
-
- )}
+ )}
+
+ );
+ })}
+
+ ))}
+
);
}
diff --git a/client/src/components/Chat/Input/Files/Table/Columns.tsx b/client/src/components/Chat/Input/Files/Table/Columns.tsx
index dcf043b7647..01195dfc559 100644
--- a/client/src/components/Chat/Input/Files/Table/Columns.tsx
+++ b/client/src/components/Chat/Input/Files/Table/Columns.tsx
@@ -24,6 +24,7 @@ const contextMap: Record = {
[FileContext.image_generation]: 'com_ui_image_gen',
[FileContext.assistants_output]: 'com_ui_assistants_output',
[FileContext.message_attachment]: 'com_ui_attachment',
+ [FileContext.run_artifact]: 'com_ui_published_file',
};
export const columns: ColumnDef[] = [
diff --git a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
index 383ca7cac70..f6fd8cc5804 100644
--- a/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
+++ b/client/src/components/Chat/Input/__tests__/CodeWorkspaceMenu.spec.tsx
@@ -23,7 +23,7 @@ jest.mock('@librechat/client', () => {
});
const conversation = {
- conversationId: 'conversation-1',
+ conversationId: 'new',
endpoint: 'agents',
agent_id: 'agent-1',
title: 'Code',
@@ -39,6 +39,9 @@ function workspace(overrides: Partial = {}): CodeWorkspaceR
const selected = { environmentId: environment.id, workspaceId: 'project-a' };
return {
required: true,
+ supportsEnvironmentDecisions: true,
+ locked: false,
+ mode: 'attached',
state: 'ready',
canSubmit: true,
environments: [
@@ -51,49 +54,41 @@ function workspace(overrides: Partial = {}): CodeWorkspaceR
],
selections: [selected],
resolveSelections: () => [selected],
- resolveSubmission: () => ({ codeWorkspaces: [selected] }),
+ resolveSubmission: () => ({ codeEnvironmentMode: 'attached', codeWorkspaces: [selected] }),
+ rememberSelection: jest.fn(),
...overrides,
};
}
describe('CodeWorkspaceMenu', () => {
- test('stores one unambiguous initial workspace on the conversation', () => {
+ test('shows a suggested workspace without committing the conversation decision', () => {
const setConversation = jest.fn();
render(
,
);
- const update = setConversation.mock.calls[0][0];
- expect(update(conversation)).toEqual({
- ...conversation,
- codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }],
- });
+ expect(setConversation).not.toHaveBeenCalled();
expect(screen.getByTestId('code-workspace')).toHaveTextContent('Project A');
});
- test('keeps a missing binding until the user explicitly selects a replacement', async () => {
- const savedConversation = {
- ...conversation,
- codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'removed-project' }],
- };
+ test('allows working without an attached workspace even while the worker is unavailable', async () => {
const setConversation = jest.fn();
render(
{
/>,
);
- expect(setConversation).not.toHaveBeenCalled();
- expect(screen.getByTestId('code-workspace')).toHaveTextContent('com_ui_code_workspace_missing');
await userEvent.click(screen.getByTestId('code-workspace'));
- await userEvent.click(await screen.findByText('Project A'));
+ await userEvent.click(await screen.findByText('com_ui_code_workspace_without_attached'));
+
+ const update = setConversation.mock.calls[0][0];
+ expect(update(conversation)).toEqual({
+ ...conversation,
+ codeEnvironmentMode: 'without_attached',
+ codeWorkspaces: undefined,
+ });
+ });
+ test('does not offer selection-less decisions before the API advertises support', async () => {
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByTestId('code-workspace'));
+
+ expect(
+ screen.queryByRole('menuitemradio', { name: /com_ui_code_workspace_without_attached/ }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole('menuitemradio', { name: /Project A/ })).toBeInTheDocument();
+ });
+
+ test('commits an explicit attached-workspace choice only when the user selects it', async () => {
+ const setConversation = jest.fn();
+ const rememberSelection = jest.fn();
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByTestId('code-workspace'));
+ await userEvent.click((await screen.findAllByText('Project A'))[1]);
+
+ expect(rememberSelection).toHaveBeenCalledWith({
+ environmentId: 'personal-vm',
+ workspaceId: 'project-a',
+ });
const update = setConversation.mock.calls[0][0];
- expect(update(savedConversation)).toEqual({
- ...savedConversation,
+ expect(update(conversation)).toEqual({
+ ...conversation,
+ codeEnvironmentMode: 'attached',
codeWorkspaces: [{ environmentId: 'personal-vm', workspaceId: 'project-a' }],
});
});
+
+ test('does not mark a suggested workspace selected in no-attached mode', async () => {
+ render(
+ ,
+ );
+
+ await userEvent.click(screen.getByTestId('code-workspace'));
+
+ expect(
+ screen.getByRole('menuitemradio', { name: /com_ui_code_workspace_without_attached/ }),
+ ).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('menuitemradio', { name: /Project A/ })).toHaveAttribute(
+ 'aria-checked',
+ 'false',
+ );
+ });
+
+ test('hides the chooser after the conversation decision is locked', () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId('code-workspace')).not.toBeInTheDocument();
+ });
+
+ test('shows recovery status when a locked workspace is unavailable', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByTestId('code-workspace-locked-status')).toHaveAccessibleName(
+ 'com_ui_code_workspace_unavailable. com_ui_code_workspace_locked_recovery',
+ );
+ });
});
diff --git a/client/src/components/Chat/Menus/HeaderMenu.tsx b/client/src/components/Chat/Menus/HeaderMenu.tsx
index f158685f70e..b0a91ae9e7a 100644
--- a/client/src/components/Chat/Menus/HeaderMenu.tsx
+++ b/client/src/components/Chat/Menus/HeaderMenu.tsx
@@ -1,10 +1,11 @@
import { useState, useId } from 'react';
import * as Ariakit from '@ariakit/react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
-import { Ellipsis, PlusCircle, HatGlasses, Check } from 'lucide-react';
import { DropdownPopup, TooltipAnchor, Button } from '@librechat/client';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
+import { Ellipsis, PlusCircle, HatGlasses, Check, ChartNoAxesGantt } from 'lucide-react';
import type { TStartupConfig } from 'librechat-data-provider';
+import type { TraceControl } from '~/components/Chat/Trace';
import type * as t from '~/common';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import useBookmarkItems from '~/hooks/Chat/useBookmarkItems';
@@ -22,9 +23,12 @@ import { cn } from '~/utils';
*/
export default function HeaderMenu({
startupConfig,
+ trace,
className,
}: {
startupConfig?: TStartupConfig;
+ /** Owned by the header, which also renders the desktop trace button from it. */
+ trace?: TraceControl;
className?: string;
}) {
const localize = useLocalize();
@@ -87,6 +91,15 @@ export default function HeaderMenu({
});
}
+ if (trace?.show === true) {
+ pushGroup({
+ id: 'header-trace',
+ label: localize('com_ui_trace_view'),
+ icon: ,
+ onClick: trace.open,
+ });
+ }
+
if (exportShare.show) {
pushGroup(...exportShare.items);
}
diff --git a/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
index d4893b316a0..30541a517c8 100644
--- a/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
+++ b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
@@ -190,6 +190,35 @@ describe('HeaderMenu', () => {
);
});
+ it('offers the trace in its own group only when the header says one is available', () => {
+ const { rerender } = render( );
+ expect(labels()).not.toContain('com_ui_trace_view');
+
+ rerender( );
+
+ expect(labels()).toEqual([
+ 'com_ui_bookmarks',
+ 'com_ui_add_multi_conversation',
+ 'com_ui_trace_view',
+ 'share',
+ 'export',
+ 'com_ui_temporary',
+ ]);
+ expect(rows().filter((node) => node.getAttribute('data-kind') === 'separator')).toHaveLength(3);
+ });
+
+ it('keeps the trace reachable when it is the only action left', () => {
+ mockHookState.multiConvo.show = false;
+ mockHookState.temporary.show = false;
+ mockHookState.bookmarks.show = false;
+ mockHookState.exportShare.show = false;
+
+ render( );
+
+ expect(labels()).toEqual(['com_ui_trace_view']);
+ expect(rows()[0]).toHaveAttribute('data-kind', 'item');
+ });
+
it('shows temporary chat as active to sighted users, not just assistive tech', () => {
mockHookState.temporary.isTemporary = true;
diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx
index 6d18065ea1e..9af2dae1e91 100644
--- a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx
@@ -1,6 +1,7 @@
import { useState, useMemo, useCallback } from 'react';
import copy from 'copy-to-clipboard';
import { Button } from '@librechat/client';
+import { hasToolCallErrorPrefix, stripToolCallErrorPrefix } from 'librechat-data-provider';
import CopyButton from '~/components/Messages/Content/CopyButton';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@@ -10,11 +11,10 @@ interface ContentBlock {
text?: string;
}
-const ERROR_PREFIX = /^Error:\s*(\[.*?\]\s*)*tool call failed:\s*/i;
const ERROR_INNER = /^Error\s+\w+ing to endpoint\s*\(HTTP \d+\):\s*/i;
function cleanError(text: string): string {
- let cleaned = text.replace(ERROR_PREFIX, '').trim();
+ let cleaned = stripToolCallErrorPrefix(text).trim();
cleaned = cleaned.replace(ERROR_INNER, '').trim();
if (cleaned.endsWith('Please fix your mistakes.')) {
cleaned = cleaned.slice(0, -'Please fix your mistakes.'.length).trim();
@@ -23,7 +23,7 @@ function cleanError(text: string): string {
}
export function isError(text: string): boolean {
- return ERROR_PREFIX.test(text) || text.startsWith('Error processing tool');
+ return hasToolCallErrorPrefix(text) || text.startsWith('Error processing tool');
}
function isStructuredText(text: string): boolean {
diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx
index 4a4df41eab7..2e4dd0d5e30 100644
--- a/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx
+++ b/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
-import OutputRenderer from '../OutputRenderer';
+import OutputRenderer, { isError } from '../OutputRenderer';
jest.mock('copy-to-clipboard', () => jest.fn());
@@ -21,4 +21,8 @@ describe('OutputRenderer', () => {
expect(copyPositioner).toHaveClass('absolute', 'right-0', 'top-1/2', '-translate-y-1/2');
expect(copyPositioner?.parentElement).toHaveClass('relative', 'pr-10');
});
+
+ it('does not treat text between bracketed prefixes as a tool-call error', () => {
+ expect(isError('Error: [agent] unexpected [search] tool call failed: unavailable')).toBe(false);
+ });
});
diff --git a/client/src/components/Chat/Trace/Button.tsx b/client/src/components/Chat/Trace/Button.tsx
new file mode 100644
index 00000000000..a0cd14c0d0f
--- /dev/null
+++ b/client/src/components/Chat/Trace/Button.tsx
@@ -0,0 +1,27 @@
+import { ChartNoAxesGantt } from 'lucide-react';
+import { Button, TooltipAnchor } from '@librechat/client';
+import { useLocalize } from '~/hooks';
+
+/** Header control that opens the conversation trace over the chat. */
+export default function TraceButton({ onClick }: { onClick: () => void }) {
+ const localize = useLocalize();
+ const label = localize('com_ui_trace_view');
+
+ return (
+
+
+
+ }
+ />
+ );
+}
diff --git a/client/src/components/Chat/Trace/Inspector.tsx b/client/src/components/Chat/Trace/Inspector.tsx
new file mode 100644
index 00000000000..cf931d64be3
--- /dev/null
+++ b/client/src/components/Chat/Trace/Inspector.tsx
@@ -0,0 +1,272 @@
+import { memo, useId } from 'react';
+import { X } from 'lucide-react';
+import { Button, Spinner } from '@librechat/client';
+import type { TTraceContent } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import type { TranslationKeys } from '~/hooks';
+import type { TraceNode } from './model';
+import { useConversationTraceRecordQuery } from '~/data-provider';
+import { useTraceFormat, recordDurationText } from './format';
+import { formatCost, formatTokens } from '~/utils/tokens';
+import { KIND_APPEARANCE, STATUS_LABEL } from './kinds';
+import { formatJSON } from '~/utils/json';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+type Field = { label: TranslationKeys; value: ReactNode };
+
+function Section({ title, fields }: { title: TranslationKeys; fields: Field[] }) {
+ const localize = useLocalize();
+ if (fields.length === 0) {
+ return null;
+ }
+ return (
+
+
+ {localize(title)}
+
+
+ {fields.map((field) => (
+
+
{localize(field.label)}
+
+ {field.value}
+
+
+ ))}
+
+
+ );
+}
+
+function ContentBlock({ label, content }: { label: TranslationKeys; content?: TTraceContent }) {
+ const localize = useLocalize();
+ if (!content) {
+ return null;
+ }
+ const text = content.truncated ? content.value : formatJSON(content.value);
+ return (
+
+
+ {localize(label)}
+
+
+ {text}
+
+ {content.truncated && (
+ {localize('com_ui_trace_truncated')}
+ )}
+
+ );
+}
+
+function RecordContent({
+ conversationId,
+ recordId,
+ messageId,
+ sourceId,
+}: {
+ conversationId: string;
+ recordId: string;
+ messageId: string;
+ sourceId?: string;
+}) {
+ const localize = useLocalize();
+ const { data, isLoading, isError, refetch } = useConversationTraceRecordQuery(
+ { conversationId, recordId, messageId, sourceId },
+ true,
+ );
+
+ if (isLoading) {
+ return (
+
+
+ {localize('com_ui_trace_loading')}
+
+ );
+ }
+ if (isError || !data) {
+ return (
+
+
{localize('com_ui_trace_content_error')}
+
refetch()}>
+ {localize('com_ui_retry')}
+
+
+ );
+ }
+ if (!data.contentAvailable) {
+ return {localize('com_ui_trace_content_hidden')}
;
+ }
+ if (!data.input && !data.output && !data.metadata) {
+ return {localize('com_ui_trace_content_empty')}
;
+ }
+ return (
+
+
+
+
+
+ );
+}
+
+/** Details for the selected record; input and output load only when the deployment allows them. */
+function Inspector({
+ node,
+ turnStart,
+ sourceId,
+ conversationId,
+ showContent,
+ showCost,
+ currency,
+ onClose,
+}: {
+ node: TraceNode;
+ turnStart: number;
+ /** The page source that listed this record. */
+ sourceId?: string;
+ conversationId: string;
+ showContent: boolean;
+ showCost: boolean;
+ currency?: { code: string; rate: number };
+ onClose: () => void;
+}) {
+ const localize = useLocalize();
+ const format = useTraceFormat();
+ const headingId = useId();
+ const { record } = node;
+ const appearance = KIND_APPEARANCE[record.kind];
+ const Icon = appearance.icon;
+ const { usage } = record;
+
+ const timing: Field[] = [
+ { label: 'com_ui_trace_started', value: format.clock(node.start) },
+ { label: 'com_ui_trace_offset', value: format.duration(node.start - turnStart) },
+ {
+ label: 'com_ui_trace_column_duration',
+ value: recordDurationText(node, format, localize(STATUS_LABEL.running)),
+ },
+ ];
+ if (node.firstToken != null) {
+ timing.push({
+ label: 'com_ui_trace_ttft',
+ value: format.duration(node.firstToken - node.start),
+ });
+ if (node.end != null) {
+ timing.push({
+ label: 'com_ui_trace_decoding',
+ value: format.duration(node.end - node.firstToken),
+ });
+ }
+ }
+
+ const usageFields: Field[] = (
+ [
+ ['com_ui_trace_usage_input', usage?.input],
+ ['com_ui_trace_usage_output', usage?.output],
+ ['com_ui_trace_usage_reasoning', usage?.reasoning],
+ ['com_ui_trace_usage_cache_read', usage?.cacheRead],
+ ['com_ui_trace_usage_cache_write', usage?.cacheWrite],
+ ['com_ui_trace_usage_total', usage?.total],
+ ] as Array<[TranslationKeys, number | undefined]>
+ )
+ .filter((entry): entry is [TranslationKeys, number] => entry[1] != null)
+ .map(([label, value]) => ({ label, value: formatTokens(value) }));
+ if (showCost && record.cost != null) {
+ usageFields.push({
+ label: 'com_ui_trace_summary_cost',
+ value: formatCost(record.cost, currency),
+ });
+ }
+
+ return (
+
+
+
+
+ {record.name}
+
+
+
+
+
+
+
+
+ {localize(appearance.label)}
+
+
+ {localize(STATUS_LABEL[record.status])}
+
+
+ {record.statusMessage != null && (
+
+ {record.statusMessage}
+
+ )}
+
+
+
+
{record.id},
+ },
+ {
+ label: 'com_ui_trace_trace_id',
+ value: {record.traceId} ,
+ },
+ ]}
+ />
+ {showContent && (
+
+
+ {localize('com_ui_trace_content')}
+
+
+
+ )}
+
+
+ );
+}
+
+export default memo(Inspector);
diff --git a/client/src/components/Chat/Trace/Ledger.tsx b/client/src/components/Chat/Trace/Ledger.tsx
new file mode 100644
index 00000000000..2631c73b864
--- /dev/null
+++ b/client/src/components/Chat/Trace/Ledger.tsx
@@ -0,0 +1,394 @@
+import { memo, useId, useMemo, useState } from 'react';
+import { ChevronRight, CircleAlert, CircleDashed } from 'lucide-react';
+import type { RefObject, CSSProperties, KeyboardEvent } from 'react';
+import type { TraceModel, TraceNode, TraceRow, TraceTurn, TraceWindow } from './model';
+import { useTraceFormat, recordDurationText } from './format';
+import { KIND_APPEARANCE, STATUS_LABEL } from './kinds';
+import { formatTokens } from '~/utils/tokens';
+import { useRowWindow } from './virtual';
+import { useLocalize } from '~/hooks';
+import { turnKey } from './model';
+import { cn } from '~/utils';
+
+const ROW_HEIGHT = 32;
+const HEADER_HEIGHT = 28;
+const INDENT_PX = 14;
+/** Shown only while the tree itself has keyboard focus, so a pointer user sees just the selection. */
+const ACTIVE_RING =
+ 'group-focus-visible/tree:ring-2 group-focus-visible/tree:ring-inset group-focus-visible/tree:ring-ring-primary';
+const GRID =
+ 'grid grid-cols-[minmax(0,1fr)_4.5rem_minmax(5rem,1fr)] md:grid-cols-[minmax(14rem,2fr)_5rem_4.5rem_minmax(10rem,3fr)]';
+
+type Domain = { start: number; span: number };
+
+const barStyle = (start: number, end: number, domain: Domain): CSSProperties => ({
+ left: `${((start - domain.start) / domain.span) * 100}%`,
+ width: `${((end - start) / domain.span) * 100}%`,
+});
+
+function RecordBar({ node, domain }: { node: TraceNode; domain: Domain }) {
+ const { record } = node;
+ const appearance = KIND_APPEARANCE[record.kind];
+ const solid = record.status === 'error' ? 'bg-status-error' : appearance.bar;
+
+ if (node.end == null) {
+ return (
+
+ );
+ }
+
+ const duration = node.end - node.start;
+ const ttftShare =
+ node.firstToken != null && duration > 0 ? (node.firstToken - node.start) / duration : 0;
+ return (
+
+ {ttftShare > 0 && (
+
+ )}
+
+
+ );
+}
+
+function TurnBar({ turn, domain }: { turn: TraceTurn; domain: Domain }) {
+ return (
+
+ );
+}
+
+function recordTokens(node: TraceNode): string {
+ const usage = node.record.usage;
+ if (node.record.kind !== 'generation' || usage == null) {
+ return '';
+ }
+ const total = usage.total ?? (usage.input ?? 0) + (usage.output ?? 0);
+ return total > 0 ? formatTokens(total) : '';
+}
+
+const toDomain = (start: number, end: number): Domain => ({
+ start,
+ span: Math.max(end - start, 1),
+});
+
+/**
+ * The hierarchical record list: one row per turn and per record. Each bar is
+ * scaled to its own response, so a short turn in a long conversation stays
+ * legible, until an interval is focused and every row shares that scale. Rows
+ * are windowed, and the tree follows the ARIA tree pattern through
+ * `aria-activedescendant`, which must name a mounted row, so the active row
+ * stays rendered while the window scrolls away from it.
+ */
+function Ledger({
+ rows,
+ model,
+ view,
+ selectedId,
+ treeRef,
+ onSelect,
+ onToggle,
+}: {
+ rows: TraceRow[];
+ model: TraceModel;
+ view: TraceWindow | null;
+ selectedId: string | null;
+ treeRef: RefObject;
+ onSelect: (id: string) => void;
+ onToggle: (key: string) => void;
+}) {
+ const localize = useLocalize();
+ const format = useTraceFormat();
+ const idPrefix = useId();
+ const scrollRef = treeRef;
+ const [activeKey, setActiveKey] = useState(null);
+ const { first, last } = useRowWindow(scrollRef, rows.length, ROW_HEIGHT, {
+ offset: HEADER_HEIGHT,
+ });
+
+ const indexByKey = useMemo(() => new Map(rows.map((row, index) => [row.key, index])), [rows]);
+ const turnDomains = useMemo(
+ () => new Map(model.turns.map((turn) => [turn.messageId, toDomain(turn.start, turn.end)])),
+ [model],
+ );
+ const viewDomain = useMemo(() => (view ? toDomain(view.start, view.end) : null), [view]);
+ const domainFor = (messageId: string): Domain =>
+ viewDomain ?? turnDomains.get(messageId) ?? toDomain(model.start, model.end);
+ const activeIndex = activeKey != null ? (indexByKey.get(activeKey) ?? 0) : 0;
+ const activeRow = rows[activeIndex];
+ const rowId = (key: string) => `${idPrefix}-${key}`;
+
+ /** Keeps a keyboard-moved row inside the viewport so its windowed element mounts. */
+ const scrollIntoView = (index: number) => {
+ const element = scrollRef.current;
+ if (!element) {
+ return;
+ }
+ const top = index * ROW_HEIGHT;
+ if (top < element.scrollTop) {
+ element.scrollTop = top;
+ } else if (HEADER_HEIGHT + top + ROW_HEIGHT > element.scrollTop + element.clientHeight) {
+ element.scrollTop = HEADER_HEIGHT + top + ROW_HEIGHT - element.clientHeight;
+ }
+ };
+
+ const moveTo = (index: number) => {
+ const clamped = Math.min(Math.max(index, 0), rows.length - 1);
+ const row = rows[clamped];
+ scrollIntoView(clamped);
+ if (row) {
+ setActiveKey(row.key);
+ }
+ };
+
+ const activate = (row: TraceRow) => {
+ setActiveKey(row.key);
+ if (row.type === 'record') {
+ onSelect(row.key);
+ } else {
+ onToggle(row.key);
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (!activeRow) {
+ return;
+ }
+ const expandable = activeRow.type === 'turn' || activeRow.hasChildren;
+ const pageSize = Math.max(1, Math.floor((scrollRef.current?.clientHeight ?? 0) / ROW_HEIGHT));
+ const handlers: Record void> = {
+ ArrowDown: () => moveTo(activeIndex + 1),
+ ArrowUp: () => moveTo(activeIndex - 1),
+ PageDown: () => moveTo(activeIndex + pageSize),
+ PageUp: () => moveTo(activeIndex - pageSize),
+ Home: () => moveTo(0),
+ End: () => moveTo(rows.length - 1),
+ ArrowRight: () => {
+ if (expandable && !activeRow.expanded) {
+ onToggle(activeRow.key);
+ } else if (expandable) {
+ moveTo(activeIndex + 1);
+ }
+ },
+ ArrowLeft: () => {
+ if (expandable && activeRow.expanded) {
+ onToggle(activeRow.key);
+ return;
+ }
+ if (activeRow.type === 'record') {
+ const { node } = activeRow;
+ const parentKey = node.parentId ?? turnKey(node.record.messageId);
+ const parentIndex = indexByKey.get(parentKey);
+ if (parentIndex != null) {
+ moveTo(parentIndex);
+ }
+ }
+ },
+ Enter: () => activate(activeRow),
+ ' ': () => activate(activeRow),
+ };
+ const handler = handlers[event.key];
+ if (handler) {
+ event.preventDefault();
+ handler();
+ }
+ };
+
+ const renderRow = (row: TraceRow, index: number) => {
+ const style: CSSProperties = { top: HEADER_HEIGHT + index * ROW_HEIGHT, height: ROW_HEIGHT };
+ const active = activeRow?.key === row.key;
+ const common = {
+ id: rowId(row.key),
+ role: 'treeitem',
+ 'aria-posinset': row.position,
+ 'aria-setsize': row.setSize,
+ style,
+ onClick: () => activate(row),
+ };
+
+ if (row.type === 'turn') {
+ const { turn } = row;
+ const label = localize('com_ui_trace_turn', { 0: format.clock(turn.start) });
+ return (
+
+
+
+ {label}
+ {turn.errorCount > 0 && (
+
+ )}
+
+
+ {format.duration(turn.end - turn.start)}
+
+
+
+
+
+
+ );
+ }
+
+ const { node } = row;
+ const { record } = node;
+ const turnStart = turnDomains.get(record.messageId)?.start ?? model.start;
+ const appearance = KIND_APPEARANCE[record.kind];
+ const Icon = appearance.icon;
+ const running = record.status === 'running';
+ const duration = recordDurationText(node, format, localize(STATUS_LABEL.running));
+ const statusText =
+ record.status === 'error' || record.status === 'warning'
+ ? `${duration}, ${localize(STATUS_LABEL[record.status])}`
+ : duration;
+
+ return (
+
+
+ {
+ if (row.hasChildren) {
+ event.stopPropagation();
+ setActiveKey(row.key);
+ onToggle(row.key);
+ }
+ }}
+ className="flex size-4 shrink-0 items-center justify-center"
+ >
+ {row.hasChildren && (
+
+ )}
+
+
+ {record.name}
+ {record.model != null && (
+
+ {record.model}
+
+ )}
+ {record.status === 'error' && (
+
+ )}
+ {running && (
+
+ )}
+
+
+ {duration}
+
+
+ {recordTokens(node)}
+
+
+
+
+
+ );
+ };
+
+ const visibleRows: JSX.Element[] = [];
+ for (let index = first; index <= last && index < rows.length; index++) {
+ visibleRows.push(renderRow(rows[index], index));
+ }
+ if (activeRow && (activeIndex < first || activeIndex > last)) {
+ visibleRows.push(renderRow(activeRow, activeIndex));
+ }
+
+ return (
+ {
+ if (activeKey == null && rows[0]) {
+ setActiveKey(rows[0].key);
+ }
+ }}
+ data-testid="trace-ledger"
+ className="group/tree relative min-h-0 flex-1 overflow-auto focus-visible:outline-none"
+ >
+
+ {localize('com_ui_trace_column_name')}
+ {localize('com_ui_trace_column_duration')}
+ {localize('com_ui_trace_column_tokens')}
+ {viewDomain ? (
+
+ {format.duration(viewDomain.start - model.start)}
+ {format.duration(viewDomain.start + viewDomain.span - model.start)}
+
+ ) : (
+ {localize('com_ui_trace_column_timeline')}
+ )}
+
+
+ {visibleRows}
+
+
+ );
+}
+
+export default memo(Ledger);
diff --git a/client/src/components/Chat/Trace/Summary.tsx b/client/src/components/Chat/Trace/Summary.tsx
new file mode 100644
index 00000000000..6287623643b
--- /dev/null
+++ b/client/src/components/Chat/Trace/Summary.tsx
@@ -0,0 +1,79 @@
+import { memo } from 'react';
+import type { TranslationKeys } from '~/hooks';
+import type { TraceSummary } from './model';
+import { formatCost, formatTokens } from '~/utils/tokens';
+import { useTraceFormat } from './format';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+type SummaryItem = { key: string; label: TranslationKeys; value: string; alert?: boolean };
+
+function TraceSummaryBar({
+ summary,
+ showCost,
+ currency,
+}: {
+ summary: TraceSummary;
+ showCost: boolean;
+ currency?: { code: string; rate: number };
+}) {
+ const localize = useLocalize();
+ const format = useTraceFormat();
+ const items: SummaryItem[] = [
+ {
+ key: 'duration',
+ label: 'com_ui_trace_summary_duration',
+ value: format.duration(summary.duration),
+ },
+ { key: 'turns', label: 'com_ui_trace_summary_turns', value: String(summary.turns) },
+ {
+ key: 'generations',
+ label: 'com_ui_trace_summary_generations',
+ value: String(summary.generations),
+ },
+ { key: 'tools', label: 'com_ui_trace_summary_tools', value: String(summary.toolCalls) },
+ {
+ key: 'tokens',
+ label: 'com_ui_trace_summary_tokens',
+ value: formatTokens(summary.totalTokens),
+ },
+ ];
+ if (showCost && summary.cost != null) {
+ items.push({
+ key: 'cost',
+ label: 'com_ui_trace_summary_cost',
+ value: formatCost(summary.cost, currency),
+ });
+ }
+ if (summary.errors > 0) {
+ items.push({
+ key: 'errors',
+ label: 'com_ui_trace_summary_errors',
+ value: String(summary.errors),
+ alert: true,
+ });
+ }
+
+ return (
+
+ {items.map((item) => (
+
+
{localize(item.label)}
+
+ {item.value}
+
+
+ ))}
+
+ );
+}
+
+export default memo(TraceSummaryBar);
diff --git a/client/src/components/Chat/Trace/Surface.tsx b/client/src/components/Chat/Trace/Surface.tsx
new file mode 100644
index 00000000000..4debfb970f5
--- /dev/null
+++ b/client/src/components/Chat/Trace/Surface.tsx
@@ -0,0 +1,44 @@
+import { useEffect } from 'react';
+import { useAtom } from 'jotai';
+import type { ReactNode } from 'react';
+import { traceViewerConversationAtom } from './store';
+import Viewer from './Viewer';
+import { cn } from '~/utils';
+
+/**
+ * Hosts the trace over the chat without unmounting it: the chat stays mounted
+ * and inert underneath, so closing the trace restores its scroll position,
+ * composer draft and focus target exactly as they were.
+ */
+export default function TraceSurface({
+ conversationId,
+ children,
+}: {
+ conversationId?: string | null;
+ children: ReactNode;
+}) {
+ const [traceConversationId, setTraceConversationId] = useAtom(traceViewerConversationAtom);
+ const open = conversationId != null && traceConversationId === conversationId;
+
+ useEffect(() => {
+ if (traceConversationId != null && traceConversationId !== conversationId) {
+ setTraceConversationId(null);
+ }
+ }, [conversationId, traceConversationId, setTraceConversationId]);
+
+ return (
+
+ {/* `isolate` while covered: the chat's own layers (the message rail sits at
+ z-40) would otherwise stack above the trace. */}
+
+ {children}
+
+ {open && (
+
setTraceConversationId(null)} />
+ )}
+
+ );
+}
diff --git a/client/src/components/Chat/Trace/Timeline.tsx b/client/src/components/Chat/Trace/Timeline.tsx
new file mode 100644
index 00000000000..23724377cbe
--- /dev/null
+++ b/client/src/components/Chat/Trace/Timeline.tsx
@@ -0,0 +1,245 @@
+import { memo, useId, useRef, useMemo, useState, useEffect, useCallback } from 'react';
+import type { KeyboardEvent, PointerEvent } from 'react';
+import type { TraceModel, TraceWindow } from './model';
+import { ZOOM_STEP, zoomWindow, panWindow, assignLanes, clampWindow, minimumSpan } from './model';
+import { KIND_APPEARANCE } from './kinds';
+import { useTraceFormat } from './format';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+const LANE_COUNT = 8;
+const LANE_HEIGHT = 5;
+const LANE_GAP = 1;
+const TOP_PADDING = 6;
+const DRAG_THRESHOLD_PX = 3;
+const TICKS = [0, 0.25, 0.5, 0.75, 1];
+
+type Drag = { origin: number; current: number; moved: boolean; pointerId: number };
+
+const percent = (fraction: number) => `${Math.min(Math.max(fraction, 0), 1) * 100}%`;
+
+/**
+ * The pinned overview of the whole trace. It always draws every loaded record
+ * at full scale; the focused interval is an overlay the ledger follows.
+ */
+function Timeline({
+ model,
+ view,
+ onViewChange,
+}: {
+ model: TraceModel;
+ view: TraceWindow | null;
+ onViewChange: (view: TraceWindow | null) => void;
+}) {
+ const localize = useLocalize();
+ const format = useTraceFormat();
+ const hintId = useId();
+ const surfaceRef = useRef(null);
+ const dragRef = useRef(null);
+ const [draft, setDraft] = useState<{ from: number; to: number } | null>(null);
+
+ const span = Math.max(model.end - model.start, 1);
+ const lanes = useMemo(() => assignLanes(model, LANE_COUNT), [model]);
+ const records = useMemo(() => [...model.nodes.values()], [model]);
+ const toFraction = useCallback(
+ (time: number) => (time - model.start) / span,
+ [model.start, span],
+ );
+
+ const fractionAt = (clientX: number): number => {
+ const rect = surfaceRef.current?.getBoundingClientRect();
+ if (!rect || rect.width === 0) {
+ return 0;
+ }
+ return Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
+ };
+
+ useEffect(() => {
+ const surface = surfaceRef.current;
+ if (!surface) {
+ return;
+ }
+ /** Registered natively: React's wheel listener is passive and cannot stop page scroll. */
+ const handleWheel = (event: WheelEvent) => {
+ if (event.deltaY === 0) {
+ return;
+ }
+ event.preventDefault();
+ const rect = surface.getBoundingClientRect();
+ const fraction = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0.5;
+ const anchor = model.start + Math.min(Math.max(fraction, 0), 1) * span;
+ const factor = event.deltaY > 0 ? 1 / ZOOM_STEP : ZOOM_STEP;
+ onViewChange(zoomWindow(model, view, factor, anchor));
+ };
+ surface.addEventListener('wheel', handleWheel, { passive: false });
+ return () => surface.removeEventListener('wheel', handleWheel);
+ }, [model, span, view, onViewChange]);
+
+ const handlePointerDown = (event: PointerEvent) => {
+ if (event.button !== 0) {
+ return;
+ }
+ const origin = fractionAt(event.clientX);
+ dragRef.current = { origin, current: origin, moved: false, pointerId: event.pointerId };
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ };
+
+ const handlePointerMove = (event: PointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag || drag.pointerId !== event.pointerId) {
+ return;
+ }
+ const width = surfaceRef.current?.getBoundingClientRect().width ?? 0;
+ drag.current = fractionAt(event.clientX);
+ drag.moved = drag.moved || Math.abs(drag.current - drag.origin) * width > DRAG_THRESHOLD_PX;
+ if (drag.moved) {
+ setDraft({
+ from: Math.min(drag.origin, drag.current),
+ to: Math.max(drag.origin, drag.current),
+ });
+ }
+ };
+
+ const finishDrag = (event: PointerEvent) => {
+ const drag = dragRef.current;
+ dragRef.current = null;
+ setDraft(null);
+ if (!drag || drag.pointerId !== event.pointerId || !drag.moved) {
+ return;
+ }
+ const from = Math.min(drag.origin, drag.current);
+ const to = Math.max(drag.origin, drag.current);
+ onViewChange(
+ clampWindow(
+ { start: model.start + from * span, end: model.start + to * span },
+ { start: model.start, end: model.end },
+ minimumSpan(model),
+ ),
+ );
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === '+' || event.key === '=') {
+ event.preventDefault();
+ onViewChange(zoomWindow(model, view, ZOOM_STEP));
+ } else if (event.key === '-' || event.key === '_') {
+ event.preventDefault();
+ onViewChange(zoomWindow(model, view, 1 / ZOOM_STEP));
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+ event.preventDefault();
+ onViewChange(panWindow(model, view, event.key === 'ArrowLeft' ? -1 : 1));
+ } else if (event.key === 'Escape' && view != null) {
+ event.preventDefault();
+ event.stopPropagation();
+ onViewChange(null);
+ }
+ };
+
+ const selection =
+ draft ?? (view ? { from: toFraction(view.start), to: toFraction(view.end) } : null);
+
+ return (
+
+
onViewChange(null)}
+ onContextMenu={(event) => {
+ if (view != null) {
+ event.preventDefault();
+ onViewChange(null);
+ }
+ }}
+ className="relative h-[60px] cursor-crosshair touch-none select-none overflow-hidden rounded-lg border border-border-light bg-surface-primary-alt focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
+ >
+
+ {model.turns.map((turn) => (
+
+ ))}
+ {records.map((node) => {
+ const { record } = node;
+ const lane = lanes.get(record.id) ?? 0;
+ const y = TOP_PADDING + lane * (LANE_HEIGHT + LANE_GAP);
+ const fill =
+ record.status === 'error' ? 'fill-status-error' : KIND_APPEARANCE[record.kind].fill;
+ if (node.end == null) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ {selection && (
+ <>
+
+
+
+ >
+ )}
+
+
+ {TICKS.map((tick) => (
+ {format.duration(span * tick)}
+ ))}
+
+
+ {localize('com_ui_trace_overview_hint')}
+
+
+ );
+}
+
+export default memo(Timeline);
diff --git a/client/src/components/Chat/Trace/Viewer.tsx b/client/src/components/Chat/Trace/Viewer.tsx
new file mode 100644
index 00000000000..cd7cfc64b06
--- /dev/null
+++ b/client/src/components/Chat/Trace/Viewer.tsx
@@ -0,0 +1,456 @@
+import { useId, useRef, useMemo, useState, useEffect, useCallback, useDeferredValue } from 'react';
+import axios from 'axios';
+import { useQueryClient } from '@tanstack/react-query';
+import { QueryKeys, resolveTraceViewerConfig } from 'librechat-data-provider';
+import { Button, Spinner, EmptyState, FilterInput, buttonVariants } from '@librechat/client';
+import {
+ X,
+ ZoomIn,
+ ZoomOut,
+ RefreshCw,
+ ExternalLink,
+ ChevronsUpDown,
+ ChevronsDownUp,
+ TriangleAlert,
+ ChartNoAxesGantt,
+} from 'lucide-react';
+import type { TTraceRecord, TTraceErrorCode, TTraceErrorResponse } from 'librechat-data-provider';
+import type { KeyboardEvent } from 'react';
+import type { TranslationKeys } from '~/hooks';
+import type { TraceWindow } from './model';
+import {
+ keepNewestTracePage,
+ useGetStartupConfig,
+ useGetLangfuseSessionLinkQuery,
+ useConversationTraceRecordsQuery,
+} from '~/data-provider';
+import {
+ ZOOM_STEP,
+ fitWindow,
+ zoomWindow,
+ flattenRows,
+ buildTraceModel,
+ collapsibleKeys,
+} from './model';
+import { KIND_APPEARANCE, STATUS_LABEL } from './kinds';
+import { useTraceFormat } from './format';
+import { useLocalize } from '~/hooks';
+import Inspector from './Inspector';
+import Timeline from './Timeline';
+import Summary from './Summary';
+import Ledger from './Ledger';
+import { cn } from '~/utils';
+
+const ERROR_MESSAGES: Partial> = {
+ invalid_request: 'com_ui_trace_error_changed',
+ not_found: 'com_ui_trace_error_not_found',
+ rate_limited: 'com_ui_trace_error_rate_limited',
+ timeout: 'com_ui_trace_error_timeout',
+ unauthorized: 'com_ui_trace_error_unauthorized',
+ unsupported: 'com_ui_trace_error_unsupported',
+};
+
+function errorCodeOf(error: unknown): TTraceErrorCode | undefined {
+ return axios.isAxiosError(error)
+ ? error.response?.data?.errorCode
+ : undefined;
+}
+
+function errorMessageKey(error: unknown): TranslationKeys {
+ const code = errorCodeOf(error);
+ return (code != null ? ERROR_MESSAGES[code] : undefined) ?? 'com_ui_trace_error_generic';
+}
+
+/**
+ * The conversation trace: a summary, a pinned overview of every loaded record,
+ * the record ledger and an inspector. It covers the chat surface and returns
+ * focus to whatever opened it when it closes.
+ */
+export default function Viewer({
+ conversationId,
+ onClose,
+}: {
+ conversationId: string;
+ onClose: () => void;
+}) {
+ const localize = useLocalize();
+ const queryClient = useQueryClient();
+ const format = useTraceFormat();
+ const headingId = useId();
+ const searchId = useId();
+ const closeRef = useRef(null);
+ const treeRef = useRef(null);
+ const { data: startupConfig } = useGetStartupConfig();
+ const settings = resolveTraceViewerConfig(startupConfig?.interface?.traceViewer);
+ const showCost = startupConfig?.interface?.contextCost === true;
+ const currency = startupConfig?.interface?.currency;
+
+ const recordsQuery = useConversationTraceRecordsQuery(conversationId, true);
+ const { data: langfuseSession } = useGetLangfuseSessionLinkQuery(
+ conversationId,
+ startupConfig?.langfuseConnectionAccess === true,
+ );
+
+ const [collapsed, setCollapsed] = useState>(() => new Set());
+ const [query, setQuery] = useState('');
+ const [view, setView] = useState(null);
+ const [selectedId, setSelectedId] = useState(null);
+ const [lastRead, setLastRead] = useState<'refresh' | 'older'>('refresh');
+ const labelsFor = useCallback(
+ (record: TTraceRecord) => [
+ localize(KIND_APPEARANCE[record.kind].label),
+ localize(STATUS_LABEL[record.status]),
+ ],
+ [localize],
+ );
+ /** Rereads only the newest page; older pages cannot change and reload on demand. */
+ const refresh = useCallback(() => {
+ setLastRead('refresh');
+ keepNewestTracePage(queryClient, conversationId);
+ recordsQuery.refetch();
+ /** An open record may have finished or finished ingesting since its detail loaded. */
+ queryClient.invalidateQueries([QueryKeys.conversationTraceRecord, conversationId]);
+ }, [queryClient, conversationId, recordsQuery]);
+ const loadOlder = useCallback(() => {
+ setLastRead('older');
+ recordsQuery.fetchNextPage();
+ }, [recordsQuery]);
+ const deferredQuery = useDeferredValue(query);
+
+ const pages = recordsQuery.data?.pages;
+ const records = useMemo(() => pages?.flatMap((page) => page.records) ?? [], [pages]);
+ const recordSources = useMemo(() => {
+ const sources = new Map();
+ for (const page of pages ?? []) {
+ for (const record of page.records) {
+ if (page.sourceId != null) {
+ sources.set(record.id, page.sourceId);
+ }
+ }
+ }
+ return sources;
+ }, [pages]);
+ /** The session link opens one project; offer it only when that project served every loaded page. */
+ const langfuseUrl =
+ langfuseSession?.url != null &&
+ langfuseSession.destinationId != null &&
+ pages != null &&
+ pages.length > 0 &&
+ pages.every((page) => page.sourceId === langfuseSession.destinationId)
+ ? langfuseSession.url
+ : undefined;
+ const model = useMemo(() => buildTraceModel(records), [records]);
+ const rows = useMemo(
+ () => flattenRows(model, { collapsed, query: deferredQuery, window: view, labelsFor }),
+ [model, collapsed, deferredQuery, view, labelsFor],
+ );
+ /** A refresh or a settled run trims the cache to its newest page. An interval or a selection
+ * on records that left with the older pages would hide every row, or reopen when they reload. */
+ useEffect(() => {
+ setSelectedId((id) => (id != null && !model.nodes.has(id) ? null : id));
+ setView((current) => (current != null ? fitWindow(current, model) : current));
+ }, [model]);
+ const selectedNode = selectedId != null ? model.nodes.get(selectedId) : undefined;
+ const selectedTurnStart =
+ model.turns.find((turn) => turn.messageId === selectedNode?.record.messageId)?.start ??
+ model.start;
+
+ useEffect(() => {
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ closeRef.current?.focus();
+ return () => {
+ /** Only reclaim focus the trace took with it; a close caused by navigating
+ * elsewhere leaves focus wherever that navigation put it. */
+ const active = document.activeElement;
+ if (active != null && active !== document.body) {
+ return;
+ }
+ const target = opener?.isConnected ? opener : document.getElementById('header-menu-button');
+ target?.focus();
+ };
+ }, []);
+
+ const toggle = useCallback((key: string) => {
+ setCollapsed((current) => {
+ const next = new Set(current);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ }, []);
+
+ const closeInspector = useCallback(() => {
+ setSelectedId(null);
+ /** A filter that matches nothing unmounts the tree; the search field is what is left to return to. */
+ (treeRef.current ?? document.getElementById(searchId))?.focus();
+ }, [searchId]);
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape' || event.defaultPrevented) {
+ return;
+ }
+ event.preventDefault();
+ if (selectedId != null) {
+ closeInspector();
+ return;
+ }
+ onClose();
+ };
+
+ const refreshButton = (
+
+
+
+ );
+
+ const renderBody = () => {
+ if (recordsQuery.isLoading) {
+ return (
+
+
+ {localize('com_ui_trace_loading')}
+
+ );
+ }
+
+ if (recordsQuery.isError && recordsQuery.data == null) {
+ return (
+
+
+ {localize('com_ui_retry')}
+
+ }
+ />
+
+ );
+ }
+
+ if (model.nodes.size === 0 && recordsQuery.hasNextPage !== true) {
+ return (
+
+
+ {localize('com_ui_trace_refresh')}
+
+ }
+ />
+
+ );
+ }
+
+ /** A refresh or an older page failed while earlier results stay on screen. */
+ const cachedReadFailed = recordsQuery.isError && recordsQuery.data != null;
+ return (
+ <>
+
+
+
+ {recordsQuery.hasNextPage === true && (
+
+ {localize('com_ui_trace_partial', { 0: String(records.length) })}
+
+ )}
+
+
+
+
setQuery(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Escape' && query !== '') {
+ event.preventDefault();
+ setQuery('');
+ }
+ }}
+ containerClassName="min-w-[10rem] flex-1 md:max-w-xs"
+ />
+ setCollapsed(new Set())}
+ disabled={collapsed.size === 0}
+ >
+
+ {localize('com_ui_trace_expand_all')}
+
+ setCollapsed(new Set(collapsibleKeys(model)))}
+ >
+
+ {localize('com_ui_trace_collapse_all')}
+
+
+ {view != null && (
+
+ {localize('com_ui_trace_selection', {
+ 0: format.duration(view.start - model.start),
+ 1: format.duration(view.end - model.start),
+ })}
+
+ )}
+ setView(zoomWindow(model, view, 1 / ZOOM_STEP))}
+ >
+
+
+ setView(zoomWindow(model, view, ZOOM_STEP))}
+ >
+
+
+ {view != null && (
+ setView(null)}>
+ {localize('com_ui_trace_clear_selection')}
+
+ )}
+
+
+
+
+
+ {rows.length === 0 ? (
+
+ {localize('com_ui_trace_no_matches')}
+
+ ) : (
+
+ )}
+ {(recordsQuery.hasNextPage === true || cachedReadFailed) && (
+
+ {cachedReadFailed && (
+ <>
+
+ {localize(errorMessageKey(recordsQuery.error))}
+
+
+ {localize('com_ui_retry')}
+
+ >
+ )}
+ {recordsQuery.hasNextPage === true && (
+
+ {recordsQuery.isFetchingNextPage && }
+ {localize('com_ui_trace_load_older')}
+
+ )}
+
+ )}
+
+ {selectedNode && (
+
+ )}
+
+ >
+ );
+ };
+
+ return (
+
+
+
+ {localize('com_ui_trace_title')}
+
+
+
+ {renderBody()}
+
+ );
+}
diff --git a/client/src/components/Chat/Trace/__tests__/Surface.test.tsx b/client/src/components/Chat/Trace/__tests__/Surface.test.tsx
new file mode 100644
index 00000000000..3bd9c3f1950
--- /dev/null
+++ b/client/src/components/Chat/Trace/__tests__/Surface.test.tsx
@@ -0,0 +1,265 @@
+import React, { useEffect, useState } from 'react';
+import userEvent from '@testing-library/user-event';
+import { QueryKeys, dataService } from 'librechat-data-provider';
+import { render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TStartupConfig, TTraceViewerConfig } from 'librechat-data-provider';
+import { IsolatedAtomStore } from 'test/harness';
+import useTraceControl from '../useTraceControl';
+import TraceSurface from '../Surface';
+import TraceButton from '../Button';
+
+jest.mock('librechat-data-provider', () => {
+ const actual =
+ jest.requireActual('librechat-data-provider');
+ return { ...actual, dataService: { ...actual.dataService } };
+});
+
+type MockStartupConfig = Omit, 'interface'> & {
+ interface?: Partial>;
+};
+
+let mockStartupConfig: MockStartupConfig = {};
+
+jest.mock('~/data-provider/Endpoints/queries', () => ({
+ useGetStartupConfig: () => ({ data: mockStartupConfig }),
+}));
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+}));
+
+jest.mock('@librechat/client', () => {
+ const actual = jest.requireActual('@librechat/client');
+ return {
+ ...actual,
+ TooltipAnchor: ({ render: node }: { render: React.ReactNode }) => node,
+ };
+});
+
+const mounts = { count: 0 };
+
+function Chat() {
+ const [draft, setDraft] = useState('');
+ useEffect(() => {
+ mounts.count++;
+ }, []);
+ return setDraft(e.target.value)} />;
+}
+
+function Host({
+ conversationId,
+ traceViewer = { enabled: true },
+ isSubmitting = false,
+ enabled = true,
+}: {
+ conversationId: string;
+ traceViewer?: TTraceViewerConfig;
+ isSubmitting?: boolean;
+ enabled?: boolean;
+}) {
+ const trace = useTraceControl({ conversationId, traceViewer, isSubmitting, enabled });
+ return (
+
+
+ {trace.show && }
+
+
+
+ );
+}
+
+function renderHost(props: React.ComponentProps) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ logger: { log: () => undefined, warn: () => undefined, error: () => undefined },
+ });
+ const wrap = (next: React.ComponentProps) => (
+
+
+
+
+
+ );
+ const utils = render(wrap(props));
+ return {
+ ...utils,
+ client,
+ rerenderHost: (next: React.ComponentProps) => utils.rerender(wrap(next)),
+ };
+}
+
+const traceButton = () => screen.queryByTestId('header-trace-button');
+const chatPane = () => screen.getByTestId('chat-pane').parentElement as HTMLElement;
+
+describe('trace entry point and surface', () => {
+ let availability: jest.SpiedFunction;
+
+ beforeEach(() => {
+ mounts.count = 0;
+ mockStartupConfig = { interface: { traceViewer: { enabled: true } } };
+ availability = jest
+ .spyOn(dataService, 'getConversationTraceAvailability')
+ .mockResolvedValue({ available: true });
+ jest.spyOn(dataService, 'getConversationTraceRecords').mockResolvedValue({ records: [] });
+ });
+
+ it('shows the control only for an available trace on an enabled deployment', async () => {
+ renderHost({ conversationId: 'convo-1' });
+
+ expect(await screen.findByTestId('header-trace-button')).toBeInTheDocument();
+ expect(availability).toHaveBeenCalledWith('convo-1');
+ });
+
+ it.each([
+ ['the viewer is disabled', { conversationId: 'convo-1', traceViewer: { enabled: false } }],
+ ['the chat is new', { conversationId: 'new' }],
+ ['the host opts out', { conversationId: 'convo-1', enabled: false }],
+ ])('never asks for availability when %s', async (_label, props) => {
+ renderHost(props);
+
+ await waitFor(() => expect(traceButton()).not.toBeInTheDocument());
+ expect(availability).not.toHaveBeenCalled();
+ });
+
+ it('hides the control for a conversation with no sampled trace', async () => {
+ availability.mockResolvedValue({ available: false });
+ renderHost({ conversationId: 'convo-1' });
+
+ await waitFor(() => expect(availability).toHaveBeenCalled());
+ expect(traceButton()).not.toBeInTheDocument();
+ });
+
+ it('re-reads availability once a running response settles', async () => {
+ availability.mockResolvedValue({ available: false });
+ const { rerenderHost } = renderHost({ conversationId: 'convo-1' });
+ await waitFor(() => expect(availability).toHaveBeenCalledTimes(1));
+
+ rerenderHost({ conversationId: 'convo-1', isSubmitting: true });
+ availability.mockResolvedValue({ available: true });
+ expect(availability).toHaveBeenCalledTimes(1);
+
+ rerenderHost({ conversationId: 'convo-1', isSubmitting: false });
+
+ expect(await screen.findByTestId('header-trace-button')).toBeInTheDocument();
+ expect(availability).toHaveBeenCalledTimes(2);
+ });
+
+ it('reads the trace again after a response settles instead of reusing cached pages', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValue({ records: [] });
+ const { rerenderHost } = renderHost({ conversationId: 'convo-1' });
+ const close = () => userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_close' }));
+
+ await userEvent.click(await screen.findByTestId('header-trace-button'));
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
+ await close();
+ await userEvent.click(screen.getByTestId('header-trace-button'));
+ await close();
+ expect(list).toHaveBeenCalledTimes(1);
+
+ rerenderHost({ conversationId: 'convo-1', isSubmitting: true });
+ rerenderHost({ conversationId: 'convo-1', isSubmitting: false });
+ await userEvent.click(await screen.findByTestId('header-trace-button'));
+
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
+ });
+
+ it('marks cached record details stale too when a response settles', async () => {
+ const { rerenderHost, client } = renderHost({ conversationId: 'convo-1' });
+ const detailKey = [QueryKeys.conversationTraceRecord, 'convo-1', 'obs-1', null];
+ client.setQueryData(detailKey, { record: {}, contentAvailable: true });
+
+ rerenderHost({ conversationId: 'convo-1', isSubmitting: true });
+ rerenderHost({ conversationId: 'convo-1', isSubmitting: false });
+
+ await waitFor(() => expect(client.getQueryState(detailKey)?.isInvalidated).toBe(true));
+ });
+
+ it('retries a transient availability failure instead of hiding the control for good', async () => {
+ availability
+ .mockRejectedValueOnce(
+ Object.assign(new Error('unavailable'), {
+ isAxiosError: true,
+ response: { status: 503 },
+ }),
+ )
+ .mockResolvedValue({ available: true });
+ const client = new QueryClient({
+ defaultOptions: { queries: { retryDelay: 1 } },
+ logger: { log: () => undefined, warn: () => undefined, error: () => undefined },
+ });
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByTestId('header-trace-button')).toBeInTheDocument();
+ expect(availability).toHaveBeenCalledTimes(2);
+ });
+
+ it('checks availability again when the server cannot decide yet', async () => {
+ availability
+ .mockResolvedValueOnce({ available: false, retryAfterMs: 20 })
+ .mockResolvedValue({ available: true });
+ renderHost({ conversationId: 'convo-1' });
+
+ expect(await screen.findByTestId('header-trace-button')).toBeInTheDocument();
+ expect(availability).toHaveBeenCalledTimes(2);
+ });
+
+ it('covers the chat without unmounting it and restores it with focus on close', async () => {
+ renderHost({ conversationId: 'convo-1' });
+ await userEvent.type(screen.getByLabelText('composer'), 'unsent draft');
+ const button = await screen.findByTestId('header-trace-button');
+
+ await userEvent.click(button);
+
+ expect(await screen.findByTestId('trace-viewer')).toBeInTheDocument();
+ expect(chatPane()).toHaveAttribute('inert');
+ expect(chatPane()).toHaveClass('isolate');
+ expect(screen.getByRole('button', { name: 'com_ui_trace_close' })).toHaveFocus();
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_close' }));
+
+ expect(screen.queryByTestId('trace-viewer')).not.toBeInTheDocument();
+ expect(chatPane()).not.toHaveAttribute('inert');
+ expect(screen.getByLabelText('composer')).toHaveValue('unsent draft');
+ expect(mounts.count).toBe(1);
+ expect(screen.getByTestId('header-trace-button')).toHaveFocus();
+ });
+
+ it('leaves focus where a navigation put it when that navigation closes the trace', async () => {
+ const outside = document.createElement('button');
+ outside.textContent = 'sidebar link';
+ document.body.appendChild(outside);
+ const { rerenderHost, client } = renderHost({ conversationId: 'convo-1' });
+ /** The next chat also has a trace, so the opener stays mounted and could steal focus back. */
+ client.setQueryData([QueryKeys.conversationTraceAvailability, 'convo-2'], { available: true });
+ await userEvent.click(await screen.findByTestId('header-trace-button'));
+ expect(await screen.findByTestId('trace-viewer')).toBeInTheDocument();
+
+ outside.focus();
+ rerenderHost({ conversationId: 'convo-2' });
+
+ expect(screen.queryByTestId('trace-viewer')).not.toBeInTheDocument();
+ expect(outside).toHaveFocus();
+ outside.remove();
+ });
+
+ it('does not carry an open trace into another conversation', async () => {
+ const { rerenderHost } = renderHost({ conversationId: 'convo-1' });
+ await userEvent.click(await screen.findByTestId('header-trace-button'));
+ expect(await screen.findByTestId('trace-viewer')).toBeInTheDocument();
+
+ rerenderHost({ conversationId: 'convo-2' });
+ expect(screen.queryByTestId('trace-viewer')).not.toBeInTheDocument();
+
+ rerenderHost({ conversationId: 'convo-1' });
+ expect(screen.queryByTestId('trace-viewer')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/Trace/__tests__/Viewer.test.tsx b/client/src/components/Chat/Trace/__tests__/Viewer.test.tsx
new file mode 100644
index 00000000000..2c1f6ea72e5
--- /dev/null
+++ b/client/src/components/Chat/Trace/__tests__/Viewer.test.tsx
@@ -0,0 +1,632 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { dataService } from 'librechat-data-provider';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, render, screen, waitFor, within, fireEvent } from '@testing-library/react';
+import type { TTracePage, TTraceRecord, TStartupConfig } from 'librechat-data-provider';
+import { keepNewestTracePage } from '~/data-provider/Traces/queries';
+import Viewer from '../Viewer';
+
+jest.mock('librechat-data-provider', () => {
+ const actual =
+ jest.requireActual('librechat-data-provider');
+ return { ...actual, dataService: { ...actual.dataService } };
+});
+
+type MockStartupConfig = Omit, 'interface'> & {
+ interface?: Partial>;
+};
+
+let mockStartupConfig: MockStartupConfig = {};
+
+jest.mock('~/data-provider/Endpoints/queries', () => ({
+ useGetStartupConfig: () => ({ data: mockStartupConfig }),
+}));
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string, options?: Record) =>
+ options ? `${key} ${Object.values(options).join(' ')}` : key,
+}));
+
+const BASE = Date.UTC(2026, 8, 12, 11, 30, 0);
+const at = (offsetMs: number) => new Date(BASE + offsetMs).toISOString();
+
+function record(overrides: Partial & Pick): TTraceRecord {
+ return {
+ traceId: 'trace-1',
+ messageId: 'response-1',
+ parentId: null,
+ kind: 'span',
+ name: overrides.id,
+ startTime: at(0),
+ endTime: at(1000),
+ status: 'ok',
+ ...overrides,
+ };
+}
+
+const records: TTraceRecord[] = [
+ record({ id: 'root', kind: 'agent', name: 'AgentGraph', startTime: at(0), endTime: at(4000) }),
+ record({
+ id: 'llm',
+ parentId: 'root',
+ kind: 'generation',
+ name: 'llm',
+ model: 'gpt-5',
+ startTime: at(100),
+ endTime: at(2100),
+ completionStartTime: at(600),
+ usage: { input: 1200, output: 300, total: 1500 },
+ cost: 0.02,
+ }),
+ record({
+ id: 'tool',
+ parentId: 'root',
+ kind: 'tool',
+ name: 'web_search',
+ status: 'error',
+ statusMessage: 'Error: search backend unavailable',
+ startTime: at(2200),
+ endTime: at(3000),
+ }),
+];
+
+function axiosError(status: number, errorCode: string) {
+ return Object.assign(new Error(errorCode), {
+ isAxiosError: true,
+ response: { status, data: { error: errorCode, errorCode } },
+ });
+}
+
+function renderViewer(onClose = jest.fn()) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ logger: { log: () => undefined, warn: () => undefined, error: () => undefined },
+ });
+ const utils = render(
+
+
+ ,
+ );
+ return { ...utils, onClose, client };
+}
+
+const treeItem = (name: RegExp) => screen.findByRole('treeitem', { name });
+
+describe('Trace Viewer', () => {
+ beforeEach(() => {
+ mockStartupConfig = { interface: { traceViewer: { enabled: true } } };
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValue({ records, sourceId: 'tenant-project' } satisfies TTracePage);
+ jest.spyOn(dataService, 'getConversationTraceRecord').mockResolvedValue({
+ record: records[1],
+ contentAvailable: true,
+ input: { value: '{"messages":[{"role":"user","content":"hi"}]}', truncated: false },
+ output: { value: 'partial outp', truncated: true },
+ });
+ jest.spyOn(dataService, 'getLangfuseSessionLink').mockResolvedValue({
+ url: 'https://langfuse.test/project/p/sessions/convo-1',
+ destinationId: 'tenant-project',
+ });
+ });
+
+ it('shows a loading state, then the summary, overview and record tree', async () => {
+ renderViewer();
+
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_trace_loading');
+ expect(await treeItem(/AgentGraph/)).toBeInTheDocument();
+ expect(screen.getByRole('treeitem', { name: /llm/ })).toHaveAttribute('aria-level', '3');
+ expect(
+ screen.getByRole('treeitem', { name: /web_search.*com_ui_trace_status_error/ }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('treeitem', { name: /^com_ui_trace_turn/ })).toHaveAttribute(
+ 'aria-level',
+ '1',
+ );
+
+ const summary = screen.getByLabelText('com_ui_trace_summary');
+ expect(
+ within(summary).getByText('com_ui_trace_summary_generations').nextSibling,
+ ).toHaveTextContent('1');
+ expect(within(summary).getByText('com_ui_trace_summary_errors').nextSibling).toHaveTextContent(
+ '1',
+ );
+ expect(within(summary).queryByText('com_ui_trace_summary_cost')).not.toBeInTheDocument();
+ expect(screen.getByTestId('trace-overview')).toBeInTheDocument();
+ expect(dataService.getConversationTraceRecords).toHaveBeenCalledWith(
+ { conversationId: 'convo-1', cursor: undefined },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('shows cost only when the deployment shows context cost', async () => {
+ mockStartupConfig = { interface: { traceViewer: { enabled: true }, contextCost: true } };
+ renderViewer();
+
+ await treeItem(/AgentGraph/);
+ expect(screen.getByText('com_ui_trace_summary_cost')).toBeInTheDocument();
+ });
+
+ it('explains a failure by its error code and recovers on retry', async () => {
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockRejectedValueOnce(axiosError(501, 'unsupported'));
+ renderViewer();
+
+ expect(await screen.findByText('com_ui_trace_error_unsupported')).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
+
+ expect(await treeItem(/AgentGraph/)).toBeInTheDocument();
+ });
+
+ it('falls back to a generic message for an unknown failure', async () => {
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockRejectedValueOnce(new Error('network down'));
+ renderViewer();
+
+ expect(await screen.findByText('com_ui_trace_error_generic')).toBeInTheDocument();
+ });
+
+ it('shows an empty state that can refresh when no records have arrived yet', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValueOnce({ records: [] });
+ renderViewer();
+
+ const title = await screen.findByText('com_ui_trace_empty_title');
+ const emptyState = title.parentElement as HTMLElement;
+ await userEvent.click(within(emptyState).getByRole('button', { name: 'com_ui_trace_refresh' }));
+
+ expect(await treeItem(/AgentGraph/)).toBeInTheDocument();
+ expect(list).toHaveBeenCalledTimes(2);
+ });
+
+ it('inspects a record without requesting content the deployment withholds', async () => {
+ renderViewer();
+
+ await userEvent.click(await treeItem(/llm/));
+
+ const inspector = screen.getByTestId('trace-inspector');
+ expect(within(inspector).getByRole('heading', { name: 'llm' })).toBeInTheDocument();
+ expect(within(inspector).getByText('com_ui_trace_ttft')).toBeInTheDocument();
+ expect(within(inspector).getByText('com_ui_trace_decoding')).toBeInTheDocument();
+ expect(within(inspector).getByText('gpt-5')).toBeInTheDocument();
+ expect(within(inspector).queryByText('com_ui_trace_content')).not.toBeInTheDocument();
+ expect(screen.getByRole('treeitem', { name: /llm/ })).toHaveAttribute('aria-selected', 'true');
+ expect(dataService.getConversationTraceRecord).not.toHaveBeenCalled();
+ });
+
+ it('loads input and output for the selected record when the deployment allows it', async () => {
+ mockStartupConfig = { interface: { traceViewer: { enabled: true, showInputOutput: true } } };
+ renderViewer();
+
+ await userEvent.click(await treeItem(/llm/));
+
+ const inspector = screen.getByTestId('trace-inspector');
+ expect(await within(inspector).findByText(/"content": "hi"/)).toBeInTheDocument();
+ expect(within(inspector).getByText('partial outp')).toBeInTheDocument();
+ expect(within(inspector).getByText('com_ui_trace_truncated')).toBeInTheDocument();
+ expect(dataService.getConversationTraceRecord).toHaveBeenCalledWith(
+ {
+ conversationId: 'convo-1',
+ recordId: 'llm',
+ messageId: 'response-1',
+ sourceId: 'tenant-project',
+ },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('shows the error message for a failed record', async () => {
+ renderViewer();
+
+ await userEvent.click(await treeItem(/web_search/));
+
+ expect(
+ within(screen.getByTestId('trace-inspector')).getByText('Error: search backend unavailable'),
+ ).toBeInTheDocument();
+ });
+
+ it('does not call a failed record with no end time running', async () => {
+ jest.spyOn(dataService, 'getConversationTraceRecords').mockResolvedValue({
+ records: [
+ record({
+ id: 'broken',
+ name: 'broken_tool',
+ kind: 'tool',
+ status: 'error',
+ endTime: undefined,
+ }),
+ ],
+ });
+ renderViewer();
+
+ const row = await treeItem(/broken_tool/);
+
+ expect(row).not.toHaveAccessibleName(/com_ui_trace_status_running/);
+ expect(row).toHaveAccessibleName(/com_ui_trace_status_error/);
+ await userEvent.click(row);
+ const inspector = screen.getByTestId('trace-inspector');
+ expect(within(inspector).queryByText('com_ui_trace_status_running')).not.toBeInTheDocument();
+ expect(within(inspector).getByText('โ')).toBeInTheDocument();
+ });
+
+ it('filters the tree by search and keeps the match context', async () => {
+ renderViewer();
+ await treeItem(/AgentGraph/);
+
+ await userEvent.type(screen.getByLabelText('com_ui_trace_search'), 'web');
+
+ await waitFor(() =>
+ expect(screen.queryByRole('treeitem', { name: /llm/ })).not.toBeInTheDocument(),
+ );
+ expect(screen.getByRole('treeitem', { name: /AgentGraph/ })).toBeInTheDocument();
+ expect(screen.getByRole('treeitem', { name: /web_search/ })).toBeInTheDocument();
+
+ await userEvent.clear(screen.getByLabelText('com_ui_trace_search'));
+ await userEvent.type(screen.getByLabelText('com_ui_trace_search'), 'nothing matches');
+ expect(await screen.findByText('com_ui_trace_no_matches')).toBeInTheDocument();
+ });
+
+ it('folds and unfolds rows from the keyboard', async () => {
+ renderViewer();
+ const root = await treeItem(/AgentGraph/);
+ const tree = screen.getByRole('tree');
+
+ await userEvent.click(root);
+ expect(root).toHaveAttribute('aria-expanded', 'true');
+ fireEvent.keyDown(tree, { key: 'ArrowLeft' });
+
+ await waitFor(() =>
+ expect(screen.queryByRole('treeitem', { name: /llm/ })).not.toBeInTheDocument(),
+ );
+ expect(screen.getByRole('treeitem', { name: /AgentGraph/ })).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
+
+ fireEvent.keyDown(tree, { key: 'ArrowRight' });
+ expect(await treeItem(/llm/)).toBeInTheDocument();
+ fireEvent.keyDown(tree, { key: 'ArrowDown' });
+ expect(tree).toHaveAttribute(
+ 'aria-activedescendant',
+ screen.getByRole('treeitem', { name: /llm/ }).id,
+ );
+ fireEvent.keyDown(tree, { key: 'Enter' });
+ expect(screen.getByTestId('trace-inspector')).toBeInTheDocument();
+ });
+
+ it('keeps the active row mounted while the ledger scrolls away from it', async () => {
+ const many = [
+ record({
+ id: 'root',
+ kind: 'agent',
+ name: 'AgentGraph',
+ startTime: at(0),
+ endTime: at(9000),
+ }),
+ ...Array.from({ length: 150 }, (_, index) =>
+ record({
+ id: `step-${index}`,
+ parentId: 'root',
+ name: `step-${index}`,
+ startTime: at(index),
+ }),
+ ),
+ ];
+ jest.spyOn(dataService, 'getConversationTraceRecords').mockResolvedValue({ records: many });
+ renderViewer();
+ await treeItem(/AgentGraph/);
+ const tree = screen.getByRole('tree');
+
+ fireEvent.focus(tree);
+ const activeId = await waitFor(() => {
+ const id = tree.getAttribute('aria-activedescendant');
+ expect(id).toBeTruthy();
+ return id as string;
+ });
+ tree.scrollTop = 140 * 32;
+ fireEvent.scroll(tree);
+
+ expect(await treeItem(/step-140/)).toBeInTheDocument();
+ expect(tree.getAttribute('aria-activedescendant')).toBe(activeId);
+ expect(document.getElementById(activeId)).toHaveAttribute('role', 'treeitem');
+ });
+
+ it('collapses and expands everything', async () => {
+ renderViewer();
+ await treeItem(/AgentGraph/);
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_collapse_all' }));
+ expect(screen.queryByRole('treeitem', { name: /AgentGraph/ })).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_expand_all' }));
+ expect(screen.getByRole('treeitem', { name: /llm/ })).toBeInTheDocument();
+ });
+
+ it('focuses an interval from the overview keyboard and clears it', async () => {
+ renderViewer();
+ await treeItem(/AgentGraph/);
+ const overview = screen.getByTestId('trace-overview');
+
+ fireEvent.keyDown(overview, { key: '+' });
+
+ expect(screen.getByTestId('trace-overview-selection')).toBeInTheDocument();
+ expect(screen.getByText(/^com_ui_trace_selection/)).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_clear_selection' }));
+ expect(screen.queryByTestId('trace-overview-selection')).not.toBeInTheDocument();
+ });
+
+ it('loads older records on demand and merges them into the tree', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockImplementation(async ({ cursor }) =>
+ cursor == null
+ ? { records: records.slice(1), nextCursor: 'older' }
+ : { records: records.slice(0, 1) },
+ );
+ renderViewer();
+
+ expect(await screen.findByText('com_ui_trace_partial 2')).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_load_older' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('treeitem', { name: /llm/ })).toHaveAttribute('aria-level', '3'),
+ );
+ expect(list).toHaveBeenLastCalledWith(
+ { conversationId: 'convo-1', cursor: 'older' },
+ expect.any(AbortSignal),
+ );
+ expect(
+ screen.queryByRole('button', { name: 'com_ui_trace_load_older' }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('reports a failed refresh while every page is already loaded, with a retry', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValueOnce({ records })
+ .mockRejectedValueOnce(axiosError(504, 'timeout'))
+ .mockResolvedValue({ records });
+ renderViewer();
+ await treeItem(/AgentGraph/);
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_refresh' }));
+
+ expect(await screen.findByRole('alert')).toHaveTextContent('com_ui_trace_error_timeout');
+ expect(screen.getByRole('treeitem', { name: /AgentGraph/ })).toBeInTheDocument();
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
+
+ await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
+ expect(list).toHaveBeenCalledTimes(3);
+ });
+
+ it('refreshes by rereading only the newest page, not every loaded page', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockImplementation(async ({ cursor }) =>
+ cursor == null
+ ? { records: records.slice(1), nextCursor: 'older' }
+ : { records: records.slice(0, 1) },
+ );
+ renderViewer();
+ await userEvent.click(await screen.findByRole('button', { name: 'com_ui_trace_load_older' }));
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_refresh' }));
+
+ expect(
+ await screen.findByRole('button', { name: 'com_ui_trace_load_older' }),
+ ).toBeInTheDocument();
+ expect(list).toHaveBeenCalledTimes(3);
+ expect(list).toHaveBeenLastCalledWith(
+ { conversationId: 'convo-1', cursor: undefined },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('drops an interval and a selection on older pages when a refresh trims them', async () => {
+ const newest = [
+ record({
+ id: 'later',
+ messageId: 'response-2',
+ traceId: 'trace-2',
+ name: 'LaterGraph',
+ kind: 'agent',
+ startTime: at(10_000),
+ endTime: at(12_000),
+ }),
+ ];
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockImplementation(async ({ cursor }) =>
+ cursor == null ? { records: newest, nextCursor: 'older' } : { records },
+ );
+ renderViewer();
+ await userEvent.click(await screen.findByRole('button', { name: 'com_ui_trace_load_older' }));
+ await userEvent.click(await treeItem(/llm/));
+ fireEvent.keyDown(screen.getByTestId('trace-overview'), { key: '+' });
+ expect(screen.queryByRole('treeitem', { name: /LaterGraph/ })).not.toBeInTheDocument();
+ expect(screen.getByTestId('trace-inspector')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_refresh' }));
+
+ expect(await treeItem(/LaterGraph/)).toBeInTheDocument();
+ expect(screen.queryByTestId('trace-overview-selection')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('trace-inspector')).not.toBeInTheDocument();
+
+ await userEvent.click(await screen.findByRole('button', { name: 'com_ui_trace_load_older' }));
+
+ expect(await treeItem(/llm/)).toBeInTheDocument();
+ expect(screen.queryByTestId('trace-inspector')).not.toBeInTheDocument();
+ });
+
+ it('keeps a newest-page selection but drops an older one when a settled run trims the cache', async () => {
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockImplementation(async ({ cursor }) =>
+ cursor == null
+ ? { records: records.slice(1), nextCursor: 'older' }
+ : { records: records.slice(0, 1) },
+ );
+ const { client } = renderViewer();
+ const inspectorHeading = () =>
+ within(screen.getByTestId('trace-inspector')).getByRole('heading', { level: 3 });
+
+ await userEvent.click(await screen.findByRole('button', { name: 'com_ui_trace_load_older' }));
+ await userEvent.click(await treeItem(/web_search/));
+ act(() => keepNewestTracePage(client, 'convo-1'));
+
+ expect(await screen.findByRole('button', { name: 'com_ui_trace_load_older' })).toBeVisible();
+ expect(inspectorHeading()).toHaveTextContent('web_search');
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_load_older' }));
+ await userEvent.click(await treeItem(/AgentGraph/));
+ expect(inspectorHeading()).toHaveTextContent('AgentGraph');
+ act(() => keepNewestTracePage(client, 'convo-1'));
+
+ await waitFor(() => expect(screen.queryByTestId('trace-inspector')).not.toBeInTheDocument());
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_load_older' }));
+
+ expect(await treeItem(/AgentGraph/)).toBeInTheDocument();
+ expect(screen.queryByTestId('trace-inspector')).not.toBeInTheDocument();
+ });
+
+ it('retries the read that failed: an older page retries that page', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValueOnce({ records: records.slice(1), nextCursor: 'older' })
+ .mockRejectedValueOnce(axiosError(504, 'timeout'))
+ .mockResolvedValue({ records: records.slice(0, 1) });
+ renderViewer();
+ await userEvent.click(await screen.findByRole('button', { name: 'com_ui_trace_load_older' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('com_ui_trace_error_timeout');
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
+
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(3));
+ expect(list).toHaveBeenLastCalledWith(
+ { conversationId: 'convo-1', cursor: 'older' },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('reloads from the newest page when an older page no longer matches the trace', async () => {
+ const list = jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValueOnce({ records: records.slice(1), nextCursor: 'older' })
+ .mockRejectedValueOnce(axiosError(400, 'invalid_request'))
+ .mockResolvedValue({ records: records.slice(1), nextCursor: 'older' });
+ renderViewer();
+ await userEvent.click(await screen.findByRole('button', { name: 'com_ui_trace_load_older' }));
+ expect(await screen.findByRole('alert')).toHaveTextContent('com_ui_trace_error_changed');
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
+
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(3));
+ expect(list).toHaveBeenLastCalledWith(
+ { conversationId: 'convo-1', cursor: undefined },
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('rereads an open record detail when the trace is refreshed', async () => {
+ mockStartupConfig = { interface: { traceViewer: { enabled: true, showInputOutput: true } } };
+ renderViewer();
+ await userEvent.click(await treeItem(/llm/));
+ await waitFor(() => expect(dataService.getConversationTraceRecord).toHaveBeenCalledTimes(1));
+
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_trace_refresh' }));
+
+ await waitFor(() => expect(dataService.getConversationTraceRecord).toHaveBeenCalledTimes(2));
+ });
+
+ it('returns focus to the search field when closing the inspector after a filter hid every row', async () => {
+ renderViewer();
+ await userEvent.click(await treeItem(/llm/));
+ const search = screen.getByLabelText('com_ui_trace_search');
+ await userEvent.type(search, 'no-such-record');
+ expect(screen.queryByRole('tree')).not.toBeInTheDocument();
+
+ const inspector = screen.getByTestId('trace-inspector');
+ await userEvent.click(
+ within(inspector).getByRole('button', { name: 'com_ui_trace_close_details' }),
+ );
+
+ expect(screen.queryByTestId('trace-inspector')).not.toBeInTheDocument();
+ expect(search).toHaveFocus();
+ });
+
+ it('closes the inspector on Escape before closing the trace', async () => {
+ const { onClose } = renderViewer();
+ await userEvent.click(await treeItem(/llm/));
+
+ await userEvent.keyboard('{Escape}');
+ expect(screen.queryByTestId('trace-inspector')).not.toBeInTheDocument();
+ expect(onClose).not.toHaveBeenCalled();
+
+ await userEvent.keyboard('{Escape}');
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('links to Langfuse only for users who manage the connection', async () => {
+ const first = renderViewer();
+ await treeItem(/AgentGraph/);
+ expect(
+ screen.queryByRole('link', { name: /com_ui_trace_open_langfuse/ }),
+ ).not.toBeInTheDocument();
+ expect(dataService.getLangfuseSessionLink).not.toHaveBeenCalled();
+ first.unmount();
+
+ mockStartupConfig = {
+ interface: { traceViewer: { enabled: true } },
+ langfuseConnectionAccess: true,
+ };
+ renderViewer();
+
+ const link = await screen.findByRole('link', { name: /com_ui_trace_open_langfuse/ });
+ expect(link).toHaveAttribute('href', 'https://langfuse.test/project/p/sessions/convo-1');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('does not link to a Langfuse project other than the one that served the trace', async () => {
+ mockStartupConfig = {
+ interface: { traceViewer: { enabled: true } },
+ langfuseConnectionAccess: true,
+ };
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockResolvedValue({ records, sourceId: 'central-project' });
+ renderViewer();
+
+ await treeItem(/AgentGraph/);
+ await waitFor(() => expect(dataService.getLangfuseSessionLink).toHaveBeenCalled());
+ expect(
+ screen.queryByRole('link', { name: /com_ui_trace_open_langfuse/ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it('cancels an in-flight trace read when the viewer closes', async () => {
+ let signal: AbortSignal | undefined;
+ jest
+ .spyOn(dataService, 'getConversationTraceRecords')
+ .mockImplementation((_params, requestSignal) => {
+ signal = requestSignal;
+ return new Promise(() => undefined);
+ });
+ const { unmount } = renderViewer();
+ await waitFor(() => expect(signal).toBeDefined());
+
+ unmount();
+
+ await waitFor(() => expect(signal?.aborted).toBe(true));
+ });
+
+ it('moves focus into the trace when it opens', async () => {
+ renderViewer();
+
+ expect(screen.getByRole('button', { name: 'com_ui_trace_close' })).toHaveFocus();
+ });
+});
diff --git a/client/src/components/Chat/Trace/__tests__/format.test.ts b/client/src/components/Chat/Trace/__tests__/format.test.ts
new file mode 100644
index 00000000000..b91a320c2b0
--- /dev/null
+++ b/client/src/components/Chat/Trace/__tests__/format.test.ts
@@ -0,0 +1,32 @@
+import { createTraceFormat } from '../format';
+
+const AFTERNOON = Date.UTC(2026, 8, 12, 13, 5, 9);
+
+describe('createTraceFormat', () => {
+ it('scales the duration unit with the magnitude', () => {
+ const { duration } = createTraceFormat('en-US');
+
+ expect(duration(12)).toMatch(/^12\s?ms$/);
+ expect(duration(1234)).toMatch(/^1\.23\s?s$/);
+ expect(duration(65_000)).toMatch(/^1\s?m(in)?\s+5\s?s$/);
+ expect(duration(3_720_000)).toMatch(/^1\s?h\s+2\s?m(in)?$/);
+ expect(duration(-5)).toMatch(/^0\s?ms$/);
+ });
+
+ it("formats numbers in the app's language rather than the runtime's", () => {
+ expect(createTraceFormat('de').duration(1234)).toContain('1,23');
+ expect(createTraceFormat('en-US').duration(1234)).toContain('1.23');
+ });
+
+ it("follows the user's clock setting over the language's own convention", () => {
+ const twentyFour = createTraceFormat('en-US', false).clock(AFTERNOON);
+ const twelve = createTraceFormat('en-GB', true).clock(AFTERNOON);
+
+ expect(twentyFour).not.toMatch(/am|pm/i);
+ expect(twelve).toMatch(/am|pm/i);
+ });
+
+ it('falls back to the runtime locale for a malformed language tag', () => {
+ expect(() => createTraceFormat('not a locale!').duration(1234)).not.toThrow();
+ });
+});
diff --git a/client/src/components/Chat/Trace/__tests__/model.test.ts b/client/src/components/Chat/Trace/__tests__/model.test.ts
new file mode 100644
index 00000000000..6dd2df9315b
--- /dev/null
+++ b/client/src/components/Chat/Trace/__tests__/model.test.ts
@@ -0,0 +1,354 @@
+import type { TTraceRecord } from 'librechat-data-provider';
+import {
+ turnKey,
+ panWindow,
+ fitWindow,
+ zoomWindow,
+ assignLanes,
+ clampWindow,
+ flattenRows,
+ buildTraceModel,
+ collapsibleKeys,
+} from '../model';
+
+const BASE = Date.UTC(2026, 8, 12, 11, 30, 0);
+const at = (offsetMs: number) => new Date(BASE + offsetMs).toISOString();
+
+function record(overrides: Partial & Pick): TTraceRecord {
+ return {
+ traceId: 'trace-1',
+ messageId: 'response-1',
+ parentId: null,
+ kind: 'span',
+ name: overrides.id,
+ startTime: at(0),
+ endTime: at(1000),
+ status: 'ok',
+ ...overrides,
+ };
+}
+
+const noFilter = { collapsed: new Set(), query: '', window: null };
+const rowKeys = (rows: ReturnType) => rows.map((row) => row.key);
+
+describe('buildTraceModel', () => {
+ it('nests records under their parents and orders turns and siblings by start', () => {
+ const model = buildTraceModel([
+ record({
+ id: 'late-turn-root',
+ messageId: 'response-2',
+ startTime: at(5000),
+ endTime: at(6000),
+ }),
+ record({ id: 'tool', parentId: 'root', kind: 'tool', startTime: at(600), endTime: at(900) }),
+ record({
+ id: 'llm',
+ parentId: 'root',
+ kind: 'generation',
+ startTime: at(100),
+ endTime: at(500),
+ }),
+ record({ id: 'root', kind: 'agent', startTime: at(0), endTime: at(1000) }),
+ ]);
+
+ expect(model.turns.map((turn) => turn.messageId)).toEqual(['response-1', 'response-2']);
+ expect(model.nodes.get('root')?.childIds).toEqual(['llm', 'tool']);
+ expect(model.nodes.get('tool')?.depth).toBe(1);
+ expect(rowKeys(flattenRows(model, noFilter))).toEqual([
+ turnKey('response-1'),
+ 'root',
+ 'llm',
+ 'tool',
+ turnKey('response-2'),
+ 'late-turn-root',
+ ]);
+ });
+
+ it('keeps records with a missing, cross-turn or cyclic parent as roots instead of dropping them', () => {
+ const model = buildTraceModel([
+ record({ id: 'orphan', parentId: 'not-loaded' }),
+ record({ id: 'other-turn', messageId: 'response-2', parentId: 'orphan' }),
+ record({ id: 'a', parentId: 'b', startTime: at(10) }),
+ record({ id: 'b', parentId: 'a', startTime: at(20) }),
+ record({ id: 'self', parentId: 'self', startTime: at(30) }),
+ ]);
+
+ const keys = rowKeys(flattenRows(model, noFilter));
+ expect(new Set(keys)).toEqual(
+ new Set([
+ turnKey('response-1'),
+ turnKey('response-2'),
+ 'orphan',
+ 'other-turn',
+ 'a',
+ 'b',
+ 'self',
+ ]),
+ );
+ expect(keys).toHaveLength(7);
+ expect(model.nodes.get('other-turn')?.parentId).toBeNull();
+ expect(model.nodes.get('self')?.parentId).toBeNull();
+ const cycle = [model.nodes.get('a'), model.nodes.get('b')];
+ expect(cycle.filter((node) => node?.parentId == null)).toHaveLength(1);
+ });
+
+ it('keeps a running record as a start with no invented duration', () => {
+ const model = buildTraceModel([
+ record({ id: 'root', startTime: at(0), endTime: at(2000) }),
+ record({
+ id: 'running',
+ parentId: 'root',
+ status: 'running',
+ startTime: at(3000),
+ endTime: undefined,
+ }),
+ ]);
+
+ expect(model.nodes.get('running')?.end).toBeUndefined();
+ expect(model.end).toBe(BASE + 3000);
+ expect(model.summary.running).toBe(1);
+ });
+
+ it('clamps first-token time inside the record and repairs an end before its start', () => {
+ const model = buildTraceModel([
+ record({
+ id: 'llm',
+ kind: 'generation',
+ startTime: at(1000),
+ endTime: at(3000),
+ completionStartTime: at(5000),
+ }),
+ record({ id: 'backwards', startTime: at(1000), endTime: at(500) }),
+ record({ id: 'invalid', startTime: 'not a date' }),
+ ]);
+
+ expect(model.nodes.get('llm')?.firstToken).toBe(BASE + 3000);
+ expect(model.nodes.get('backwards')?.end).toBe(BASE + 1000);
+ expect(model.nodes.has('invalid')).toBe(false);
+ });
+
+ it('summarizes generations, tools, tokens, cost and errors', () => {
+ const model = buildTraceModel([
+ record({ id: 'root', kind: 'agent', startTime: at(0), endTime: at(4000) }),
+ record({
+ id: 'llm-1',
+ parentId: 'root',
+ kind: 'generation',
+ usage: { input: 100, output: 20, total: 120 },
+ cost: 0.01,
+ }),
+ record({
+ id: 'llm-2',
+ parentId: 'root',
+ kind: 'generation',
+ usage: { input: 10, output: 5 },
+ cost: 0.005,
+ }),
+ record({ id: 'tool', parentId: 'root', kind: 'tool', status: 'error' }),
+ ]);
+
+ expect(model.summary).toEqual({
+ duration: 4000,
+ turns: 1,
+ generations: 2,
+ toolCalls: 1,
+ errors: 1,
+ running: 0,
+ inputTokens: 110,
+ outputTokens: 25,
+ totalTokens: 135,
+ cost: expect.closeTo(0.015),
+ });
+ expect(model.turns[0].errorCount).toBe(1);
+ });
+
+ it('withholds the cost total when any model call has no price, with or without usage', () => {
+ const priced = record({ id: 'priced', kind: 'generation', usage: { total: 100 }, cost: 0.02 });
+ const withUsage = buildTraceModel([
+ priced,
+ record({ id: 'unpriced', kind: 'generation', usage: { total: 50 } }),
+ ]);
+ const withoutUsage = buildTraceModel([priced, record({ id: 'bare', kind: 'generation' })]);
+ const toolOnly = buildTraceModel([priced, record({ id: 'tool', kind: 'tool' })]);
+
+ expect(withUsage.summary.cost).toBeUndefined();
+ expect(withUsage.summary.totalTokens).toBe(150);
+ expect(withoutUsage.summary.cost).toBeUndefined();
+ expect(toolOnly.summary.cost).toBeCloseTo(0.02);
+ });
+
+ it('keeps the last copy of a record loaded twice', () => {
+ const model = buildTraceModel([
+ record({ id: 'root', status: 'running', endTime: undefined }),
+ record({ id: 'root', status: 'ok' }),
+ ]);
+
+ expect(model.nodes.size).toBe(1);
+ expect(model.nodes.get('root')?.record.status).toBe('ok');
+ });
+});
+
+describe('flattenRows', () => {
+ const model = buildTraceModel([
+ record({ id: 'root', kind: 'agent', startTime: at(0), endTime: at(10_000) }),
+ record({
+ id: 'llm',
+ parentId: 'root',
+ kind: 'generation',
+ model: 'gpt-5',
+ startTime: at(0),
+ endTime: at(2000),
+ }),
+ record({
+ id: 'tool',
+ parentId: 'root',
+ kind: 'tool',
+ name: 'web_search',
+ startTime: at(5000),
+ endTime: at(6000),
+ }),
+ record({
+ id: 'nested',
+ parentId: 'tool',
+ name: 'fetch',
+ startTime: at(5100),
+ endTime: at(5900),
+ }),
+ ]);
+
+ it('places each row among its visible siblings rather than the whole list', () => {
+ const placements = flattenRows(model, noFilter).map(({ key, position, setSize }) => [
+ key,
+ position,
+ setSize,
+ ]);
+
+ expect(placements).toEqual([
+ [turnKey('response-1'), 1, 1],
+ ['root', 1, 1],
+ ['llm', 1, 2],
+ ['tool', 2, 2],
+ ['nested', 1, 1],
+ ]);
+ expect(
+ flattenRows(model, { ...noFilter, query: 'fetch' }).find((row) => row.key === 'tool'),
+ ).toMatchObject({ position: 1, setSize: 1 });
+ });
+
+ it('hides the descendants of a collapsed record and turn', () => {
+ expect(rowKeys(flattenRows(model, { ...noFilter, collapsed: new Set(['tool']) }))).toEqual([
+ turnKey('response-1'),
+ 'root',
+ 'llm',
+ 'tool',
+ ]);
+ expect(
+ rowKeys(flattenRows(model, { ...noFilter, collapsed: new Set([turnKey('response-1')]) })),
+ ).toEqual([turnKey('response-1')]);
+ });
+
+ it('keeps search matches and their ancestors, even inside collapsed parents', () => {
+ const rows = flattenRows(model, {
+ collapsed: new Set(['root', 'tool']),
+ query: 'FETCH',
+ window: null,
+ });
+
+ expect(rowKeys(rows)).toEqual([turnKey('response-1'), 'root', 'tool', 'nested']);
+ });
+
+ it('matches on model names', () => {
+ expect(rowKeys(flattenRows(model, { ...noFilter, query: 'gpt-5' }))).toEqual([
+ turnKey('response-1'),
+ 'root',
+ 'llm',
+ ]);
+ });
+
+ it('focuses the ledger on records active at any point in the interval', () => {
+ const rows = flattenRows(model, {
+ ...noFilter,
+ window: { start: BASE + 5500, end: BASE + 7000 },
+ });
+
+ expect(rowKeys(rows)).toEqual([turnKey('response-1'), 'root', 'tool', 'nested']);
+ });
+
+ it('matches the localized labels a row shows rather than internal kind names', () => {
+ const labelsFor = (entry: TTraceRecord) => [
+ entry.kind === 'generation' ? 'Modellaufruf' : 'Schritt',
+ entry.status === 'ok' ? 'Abgeschlossen' : 'Fehler',
+ ];
+
+ expect(rowKeys(flattenRows(model, { ...noFilter, query: 'modellauf', labelsFor }))).toEqual([
+ turnKey('response-1'),
+ 'root',
+ 'llm',
+ ]);
+ expect(flattenRows(model, { ...noFilter, query: 'generation', labelsFor })).toEqual([]);
+ });
+
+ it('drops turns with nothing left to show', () => {
+ expect(flattenRows(model, { ...noFilter, query: 'no such record' })).toEqual([]);
+ });
+
+ it('offers every turn and parent as a collapsible key', () => {
+ expect(new Set(collapsibleKeys(model))).toEqual(
+ new Set([turnKey('response-1'), 'root', 'tool']),
+ );
+ });
+});
+
+describe('time windows', () => {
+ const bounds = { start: BASE, end: BASE + 10_000 };
+
+ it('clamps a window inside the trace and clears it when it covers everything', () => {
+ expect(clampWindow({ start: BASE - 5000, end: BASE + 1000 }, bounds)).toEqual({
+ start: BASE,
+ end: BASE + 6000,
+ });
+ expect(clampWindow({ start: BASE - 1, end: BASE + 20_000 }, bounds)).toBeNull();
+ });
+
+ it('fits a window to a trace that shrank, and drops one that no longer covers it', () => {
+ const inside = { start: BASE + 1000, end: BASE + 2000 };
+ expect(fitWindow(inside, bounds)).toBe(inside);
+ expect(fitWindow({ start: BASE - 3000, end: BASE + 2000 }, bounds)).toEqual({
+ start: BASE,
+ end: BASE + 2000,
+ });
+ expect(fitWindow({ start: BASE - 3000, end: BASE - 1000 }, bounds)).toBeNull();
+ expect(fitWindow({ start: BASE - 3000, end: BASE }, bounds)).toBeNull();
+ });
+
+ it('zooms around an anchor and pans within bounds', () => {
+ const zoomed = zoomWindow(bounds, null, 0.5, BASE + 2000);
+ expect(zoomed).toEqual({ start: BASE + 1000, end: BASE + 6000 });
+ expect(zoomWindow(bounds, zoomed, 2, BASE + 2000)).toBeNull();
+ expect(panWindow(bounds, { start: BASE + 9000, end: BASE + 10_000 }, 1)).toEqual({
+ start: BASE + 9000,
+ end: BASE + 10_000,
+ });
+ expect(panWindow(bounds, { start: BASE + 1000, end: BASE + 2000 }, -1)).toEqual({
+ start: BASE + 900,
+ end: BASE + 1900,
+ });
+ });
+});
+
+describe('assignLanes', () => {
+ it('reuses a lane once its record ends and caps the lane count', () => {
+ const model = buildTraceModel([
+ record({ id: 'a', startTime: at(0), endTime: at(1000) }),
+ record({ id: 'b', startTime: at(500), endTime: at(1500) }),
+ record({ id: 'c', startTime: at(1000), endTime: at(2000) }),
+ record({ id: 'd', startTime: at(1200), endTime: at(1300) }),
+ ]);
+
+ const lanes = assignLanes(model, 2);
+ expect(lanes.get('a')).toBe(0);
+ expect(lanes.get('b')).toBe(1);
+ expect(lanes.get('c')).toBe(0);
+ expect(lanes.get('d')).toBe(1);
+ });
+});
diff --git a/client/src/components/Chat/Trace/format.ts b/client/src/components/Chat/Trace/format.ts
new file mode 100644
index 00000000000..243160b2c1f
--- /dev/null
+++ b/client/src/components/Chat/Trace/format.ts
@@ -0,0 +1,103 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { TraceNode } from './model';
+import useClockFormat from '~/hooks/useClockFormat';
+
+/** Shown for a finished record the backend gave no end, which has no measurable duration. */
+const UNKNOWN_DURATION = 'โ';
+
+export type TraceFormat = {
+ duration: (ms: number) => string;
+ clock: (epochMs: number) => string;
+};
+
+const formats = new Map();
+
+/** A malformed language tag makes `Intl` throw; it falls back to the runtime's own locale. */
+function supportedLocale(locale?: string): string | undefined {
+ if (!locale) {
+ return undefined;
+ }
+ try {
+ return Intl.DateTimeFormat.supportedLocalesOf(locale).length > 0 ? locale : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Duration and clock formatters for one locale and clock preference. Built once
+ * per pair, since every visible ledger row formats several values per render.
+ */
+export function createTraceFormat(language?: string, hour12?: boolean): TraceFormat {
+ const locale = supportedLocale(language);
+ const key = `${locale ?? ''}|${hour12 ?? ''}`;
+ const cached = formats.get(key);
+ if (cached) {
+ return cached;
+ }
+
+ const unit = (name: string, maximumFractionDigits: number) =>
+ new Intl.NumberFormat(locale, {
+ style: 'unit',
+ unit: name,
+ unitDisplay: 'narrow',
+ maximumFractionDigits,
+ });
+ const milliseconds = unit('millisecond', 0);
+ const seconds = unit('second', 2);
+ const wholeSeconds = unit('second', 0);
+ const minutes = unit('minute', 0);
+ const hours = unit('hour', 0);
+ const clock = new Intl.DateTimeFormat(locale, {
+ hour: 'numeric',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12,
+ });
+
+ const format: TraceFormat = {
+ duration: (ms) => {
+ const value = Number.isFinite(ms) && ms > 0 ? ms : 0;
+ if (value < 1000) {
+ return milliseconds.format(value);
+ }
+ if (value < 60_000) {
+ return seconds.format(value / 1000);
+ }
+ if (value < 3_600_000) {
+ return `${minutes.format(Math.floor(value / 60_000))} ${wholeSeconds.format(
+ Math.floor((value % 60_000) / 1000),
+ )}`;
+ }
+ return `${hours.format(Math.floor(value / 3_600_000))} ${minutes.format(
+ Math.floor((value % 3_600_000) / 60_000),
+ )}`;
+ },
+ clock: (epochMs) => clock.format(epochMs),
+ };
+ formats.set(key, format);
+ return format;
+}
+
+/**
+ * A record's duration as the ledger and inspector show it. Running is read from the
+ * record's status, not from a missing end: a failed record can lack an end too.
+ */
+export function recordDurationText(
+ node: TraceNode,
+ format: TraceFormat,
+ runningLabel: string,
+): string {
+ if (node.record.status === 'running') {
+ return runningLabel;
+ }
+ return node.end == null ? UNKNOWN_DURATION : format.duration(node.end - node.start);
+}
+
+/** Formats in the app's language and the user's clock setting, as message timestamps do. */
+export function useTraceFormat(): TraceFormat {
+ const { i18n } = useTranslation();
+ const hour12 = useClockFormat();
+ return useMemo(() => createTraceFormat(i18n.language, hour12), [i18n.language, hour12]);
+}
diff --git a/client/src/components/Chat/Trace/index.ts b/client/src/components/Chat/Trace/index.ts
new file mode 100644
index 00000000000..18fa96d3905
--- /dev/null
+++ b/client/src/components/Chat/Trace/index.ts
@@ -0,0 +1,5 @@
+export { default as TraceButton } from './Button';
+export { default as TraceSurface } from './Surface';
+export { default as useTraceControl } from './useTraceControl';
+export type { TraceControl } from './useTraceControl';
+export { traceViewerConversationAtom } from './store';
diff --git a/client/src/components/Chat/Trace/kinds.ts b/client/src/components/Chat/Trace/kinds.ts
new file mode 100644
index 00000000000..3f1e047f9b1
--- /dev/null
+++ b/client/src/components/Chat/Trace/kinds.ts
@@ -0,0 +1,59 @@
+import { Bot, Zap, Wrench, Sparkles, Workflow } from 'lucide-react';
+import type { TTraceRecordKind, TTraceStatus } from 'librechat-data-provider';
+import type { LucideIcon } from 'lucide-react';
+import type { TranslationKeys } from '~/hooks';
+
+type KindAppearance = {
+ icon: LucideIcon;
+ label: TranslationKeys;
+ /** Static class names: Tailwind cannot see an interpolated `bg-series-${n}`. */
+ bar: string;
+ /** The generation's time to first token, drawn as a tint of its bar. */
+ tint: string;
+ fill: string;
+};
+
+export const KIND_APPEARANCE: Record = {
+ agent: {
+ icon: Bot,
+ label: 'com_ui_trace_kind_agent',
+ bar: 'bg-series-1',
+ tint: 'bg-series-1/30',
+ fill: 'fill-series-1',
+ },
+ generation: {
+ icon: Sparkles,
+ label: 'com_ui_trace_kind_generation',
+ bar: 'bg-series-2',
+ tint: 'bg-series-2/30',
+ fill: 'fill-series-2',
+ },
+ tool: {
+ icon: Wrench,
+ label: 'com_ui_trace_kind_tool',
+ bar: 'bg-series-3',
+ tint: 'bg-series-3/30',
+ fill: 'fill-series-3',
+ },
+ span: {
+ icon: Workflow,
+ label: 'com_ui_trace_kind_span',
+ bar: 'bg-series-6',
+ tint: 'bg-series-6/30',
+ fill: 'fill-series-6',
+ },
+ event: {
+ icon: Zap,
+ label: 'com_ui_trace_kind_event',
+ bar: 'bg-series-4',
+ tint: 'bg-series-4/30',
+ fill: 'fill-series-4',
+ },
+};
+
+export const STATUS_LABEL: Record = {
+ ok: 'com_ui_trace_status_ok',
+ running: 'com_ui_trace_status_running',
+ error: 'com_ui_trace_status_error',
+ warning: 'com_ui_trace_status_warning',
+};
diff --git a/client/src/components/Chat/Trace/model.ts b/client/src/components/Chat/Trace/model.ts
new file mode 100644
index 00000000000..900684948e1
--- /dev/null
+++ b/client/src/components/Chat/Trace/model.ts
@@ -0,0 +1,453 @@
+import type { TTraceRecord } from 'librechat-data-provider';
+
+export type TraceNode = {
+ record: TTraceRecord;
+ start: number;
+ /** Absent while the record is still running: it has a start, not a duration. */
+ end?: number;
+ /** First streamed token, clamped inside the record's own span. */
+ firstToken?: number;
+ depth: number;
+ parentId: string | null;
+ childIds: string[];
+};
+
+export type TraceTurn = {
+ key: string;
+ messageId: string;
+ start: number;
+ end: number;
+ rootIds: string[];
+ recordCount: number;
+ errorCount: number;
+};
+
+export type TraceSummary = {
+ duration: number;
+ turns: number;
+ generations: number;
+ toolCalls: number;
+ errors: number;
+ running: number;
+ inputTokens: number;
+ outputTokens: number;
+ totalTokens: number;
+ cost?: number;
+};
+
+export type TraceModel = {
+ nodes: Map;
+ turns: TraceTurn[];
+ start: number;
+ end: number;
+ summary: TraceSummary;
+};
+
+export type TraceWindow = { start: number; end: number };
+
+/** `position` and `setSize` count the row's visible siblings, as a tree item's ARIA position does. */
+type RowPlacement = { key: string; expanded: boolean; position: number; setSize: number };
+
+export type TraceRow =
+ | (RowPlacement & { type: 'turn'; turn: TraceTurn })
+ | (RowPlacement & { type: 'record'; node: TraceNode; hasChildren: boolean });
+
+export type TraceFilter = {
+ collapsed: ReadonlySet;
+ query: string;
+ window: TraceWindow | null;
+ /** The labels a row shows for its record (kind and status, localized), which search also matches. */
+ labelsFor?: (record: TTraceRecord) => readonly string[];
+};
+
+const EMPTY_SUMMARY: TraceSummary = {
+ duration: 0,
+ turns: 0,
+ generations: 0,
+ toolCalls: 0,
+ errors: 0,
+ running: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ totalTokens: 0,
+};
+
+export const turnKey = (messageId: string): string => `turn:${messageId}`;
+
+const byStart = (nodes: Map) => (a: string, b: string) => {
+ const left = nodes.get(a);
+ const right = nodes.get(b);
+ return (left?.start ?? 0) - (right?.start ?? 0) || a.localeCompare(b);
+};
+
+function toNode(record: TTraceRecord): TraceNode | null {
+ const start = Date.parse(record.startTime);
+ if (!Number.isFinite(start)) {
+ return null;
+ }
+ const parsedEnd = record.endTime != null ? Date.parse(record.endTime) : Number.NaN;
+ const end = Number.isFinite(parsedEnd) ? Math.max(parsedEnd, start) : undefined;
+ const parsedToken =
+ record.completionStartTime != null ? Date.parse(record.completionStartTime) : Number.NaN;
+ const firstToken = Number.isFinite(parsedToken)
+ ? Math.min(Math.max(parsedToken, start), end ?? parsedToken)
+ : undefined;
+ return { record, start, end, firstToken, depth: 0, parentId: null, childIds: [] };
+}
+
+/**
+ * Cuts every parent cycle so each record hangs from exactly one root. A record
+ * whose parent is unloaded, belongs to another turn, or closes a loop becomes a
+ * root of its own turn instead of disappearing.
+ */
+function resolveParents(nodes: Map): void {
+ for (const [id, node] of nodes) {
+ const parentId = node.record.parentId;
+ const parent = parentId != null ? nodes.get(parentId) : undefined;
+ node.parentId =
+ parent != null && parentId !== id && parent.record.messageId === node.record.messageId
+ ? parentId
+ : null;
+ }
+
+ const settled = new Set();
+ for (const id of nodes.keys()) {
+ const path = new Set();
+ let current: string | null = id;
+ let previous: string | null = null;
+ while (current != null && !settled.has(current)) {
+ if (path.has(current)) {
+ const cutAt = nodes.get(previous ?? current);
+ if (cutAt) {
+ cutAt.parentId = null;
+ }
+ break;
+ }
+ path.add(current);
+ previous = current;
+ current = nodes.get(current)?.parentId ?? null;
+ }
+ for (const visited of path) {
+ settled.add(visited);
+ }
+ }
+}
+
+/** Projects a flat, possibly partial record list into turns of record trees. */
+export function buildTraceModel(records: readonly TTraceRecord[]): TraceModel {
+ const nodes = new Map();
+ for (const record of records) {
+ const node = toNode(record);
+ if (node) {
+ nodes.set(record.id, node);
+ }
+ }
+ if (nodes.size === 0) {
+ return { nodes, turns: [], start: 0, end: 0, summary: EMPTY_SUMMARY };
+ }
+
+ resolveParents(nodes);
+
+ const turnsByMessage = new Map();
+ const summary: TraceSummary = { ...EMPTY_SUMMARY };
+ let start = Number.POSITIVE_INFINITY;
+ let end = Number.NEGATIVE_INFINITY;
+ let cost = 0;
+ let pricedRecords = 0;
+ let unpricedRecords = 0;
+
+ for (const [id, node] of nodes) {
+ const { record } = node;
+ const nodeEnd = node.end ?? node.start;
+ start = Math.min(start, node.start);
+ end = Math.max(end, nodeEnd);
+
+ if (node.parentId != null) {
+ nodes.get(node.parentId)?.childIds.push(id);
+ }
+
+ const key = turnKey(record.messageId);
+ let turn = turnsByMessage.get(record.messageId);
+ if (!turn) {
+ turn = {
+ key,
+ messageId: record.messageId,
+ start: node.start,
+ end: nodeEnd,
+ rootIds: [],
+ recordCount: 0,
+ errorCount: 0,
+ };
+ turnsByMessage.set(record.messageId, turn);
+ }
+ turn.start = Math.min(turn.start, node.start);
+ turn.end = Math.max(turn.end, nodeEnd);
+ turn.recordCount++;
+ if (node.parentId == null) {
+ turn.rootIds.push(id);
+ }
+
+ if (record.status === 'error') {
+ turn.errorCount++;
+ summary.errors++;
+ } else if (record.status === 'running') {
+ summary.running++;
+ }
+ if (record.kind === 'tool') {
+ summary.toolCalls++;
+ }
+ if (record.kind === 'generation') {
+ summary.generations++;
+ const input = record.usage?.input ?? 0;
+ const output = record.usage?.output ?? 0;
+ const total = record.usage?.total ?? input + output;
+ summary.inputTokens += input;
+ summary.outputTokens += output;
+ summary.totalTokens += total;
+ if (record.cost == null) {
+ unpricedRecords++;
+ }
+ }
+ if (record.cost != null) {
+ pricedRecords++;
+ cost += record.cost;
+ }
+ }
+
+ const compare = byStart(nodes);
+ const turns = [...turnsByMessage.values()].sort((a, b) => a.start - b.start);
+ for (const turn of turns) {
+ turn.rootIds.sort(compare);
+ const stack = turn.rootIds.map((id) => ({ id, depth: 0 }));
+ while (stack.length > 0) {
+ const entry = stack.pop();
+ const node = entry ? nodes.get(entry.id) : undefined;
+ if (!entry || !node) {
+ continue;
+ }
+ node.depth = entry.depth;
+ node.childIds.sort(compare);
+ for (const childId of node.childIds) {
+ stack.push({ id: childId, depth: entry.depth + 1 });
+ }
+ }
+ }
+
+ summary.turns = turns.length;
+ summary.duration = end - start;
+ /** A total that silently skips a model call without a price, with or without usage, would under-report spend, so there is none. */
+ if (pricedRecords > 0 && unpricedRecords === 0) {
+ summary.cost = cost;
+ }
+ return { nodes, turns, start, end, summary };
+}
+
+function matchesQuery(
+ node: TraceNode,
+ query: string,
+ labelsFor: TraceFilter['labelsFor'],
+): boolean {
+ if (!query) {
+ return true;
+ }
+ const { record } = node;
+ return [record.name, record.model, record.statusMessage, ...(labelsFor?.(record) ?? [])].some(
+ (value) => value != null && value.toLowerCase().includes(query),
+ );
+}
+
+function overlapsWindow(node: TraceNode, window: TraceWindow | null): boolean {
+ return window == null || (node.start <= window.end && (node.end ?? node.start) >= window.start);
+}
+
+/** Records that match the filter, plus every ancestor so a match keeps its context. */
+function visibleIds(
+ model: TraceModel,
+ query: string,
+ window: TraceWindow | null,
+ labelsFor: TraceFilter['labelsFor'],
+): Set {
+ const visible = new Set();
+ for (const [id, node] of model.nodes) {
+ if (!matchesQuery(node, query, labelsFor) || !overlapsWindow(node, window)) {
+ continue;
+ }
+ let current: string | null = id;
+ while (current != null && !visible.has(current)) {
+ visible.add(current);
+ current = model.nodes.get(current)?.parentId ?? null;
+ }
+ }
+ return visible;
+}
+
+/**
+ * The ledger's rows in display order. A search or interval selection expands
+ * everything it keeps, so a match is never hidden inside a folded parent.
+ */
+export function flattenRows(
+ model: TraceModel,
+ { collapsed, query, window, labelsFor }: TraceFilter,
+): TraceRow[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ const filtering = normalizedQuery !== '' || window != null;
+ const visible = filtering ? visibleIds(model, normalizedQuery, window, labelsFor) : null;
+ const rows: TraceRow[] = [];
+
+ const turns = model.turns.flatMap((turn) => {
+ const roots = visible ? turn.rootIds.filter((id) => visible.has(id)) : turn.rootIds;
+ return visible && roots.length === 0 ? [] : [{ turn, roots }];
+ });
+
+ turns.forEach(({ turn, roots }, turnIndex) => {
+ const turnExpanded = filtering || !collapsed.has(turn.key);
+ rows.push({
+ type: 'turn',
+ key: turn.key,
+ turn,
+ expanded: turnExpanded,
+ position: turnIndex + 1,
+ setSize: turns.length,
+ });
+ if (!turnExpanded) {
+ return;
+ }
+
+ const stack = roots
+ .map((id, index) => ({ id, position: index + 1, setSize: roots.length }))
+ .reverse();
+ while (stack.length > 0) {
+ const entry = stack.pop();
+ const node = entry != null ? model.nodes.get(entry.id) : undefined;
+ if (entry == null || !node) {
+ continue;
+ }
+ const children = visible
+ ? node.childIds.filter((childId) => visible.has(childId))
+ : node.childIds;
+ const expanded = filtering || !collapsed.has(entry.id);
+ rows.push({
+ type: 'record',
+ key: entry.id,
+ node,
+ expanded,
+ hasChildren: children.length > 0,
+ position: entry.position,
+ setSize: entry.setSize,
+ });
+ if (!expanded) {
+ continue;
+ }
+ for (let i = children.length - 1; i >= 0; i--) {
+ stack.push({ id: children[i], position: i + 1, setSize: children.length });
+ }
+ }
+ });
+ return rows;
+}
+
+/** Ids a user can fold: every turn and every record with children. */
+export function collapsibleKeys(model: TraceModel): string[] {
+ const keys = model.turns.map((turn) => turn.key);
+ for (const [id, node] of model.nodes) {
+ if (node.childIds.length > 0) {
+ keys.push(id);
+ }
+ }
+ return keys;
+}
+
+/**
+ * Packs records into a fixed number of overview lanes the way a network
+ * waterfall does: each record takes the first lane free at its start.
+ */
+export function assignLanes(model: TraceModel, laneCount: number): Map {
+ const laneEnds: number[] = [];
+ const lanes = new Map();
+ const ordered = [...model.nodes.values()].sort((a, b) => a.start - b.start || a.depth - b.depth);
+ for (const node of ordered) {
+ let lane = laneEnds.findIndex((laneEnd) => laneEnd <= node.start);
+ if (lane === -1) {
+ lane = laneEnds.length < laneCount ? laneEnds.length : laneCount - 1;
+ }
+ laneEnds[lane] = Math.max(laneEnds[lane] ?? 0, node.end ?? node.start);
+ lanes.set(node.record.id, lane);
+ }
+ return lanes;
+}
+
+/** Clamps a zoom window inside the trace and keeps it at least `minSpan` wide. */
+export function clampWindow(
+ window: TraceWindow,
+ bounds: TraceWindow,
+ minSpan = 1,
+): TraceWindow | null {
+ const total = bounds.end - bounds.start;
+ if (total <= 0) {
+ return null;
+ }
+ const span = Math.min(Math.max(window.end - window.start, minSpan), total);
+ if (span >= total) {
+ return null;
+ }
+ const start = Math.min(Math.max(window.start, bounds.start), bounds.end - span);
+ return { start, end: start + span };
+}
+
+/**
+ * Keeps a zoom window on the records still loaded: the part of it inside the
+ * trace, or none when it covers none of the trace. A window that already fits
+ * is returned as is.
+ */
+export function fitWindow(
+ window: TraceWindow,
+ model: Pick,
+): TraceWindow | null {
+ const start = Math.max(window.start, model.start);
+ const end = Math.min(window.end, model.end);
+ if (end <= start) {
+ return null;
+ }
+ if (start === window.start && end === window.end) {
+ return window;
+ }
+ return clampWindow({ start, end }, { start: model.start, end: model.end }, minimumSpan(model));
+}
+
+export const ZOOM_STEP = 0.5;
+const PAN_STEP = 0.1;
+
+/** Rescales `view` by `factor` around `anchor`, an absolute timestamp. */
+export function zoomWindow(
+ model: Pick,
+ view: TraceWindow | null,
+ factor: number,
+ anchor?: number,
+): TraceWindow | null {
+ const bounds = { start: model.start, end: model.end };
+ const current = view ?? bounds;
+ const pivot = anchor ?? (current.start + current.end) / 2;
+ const start = pivot - (pivot - current.start) * factor;
+ const end = pivot + (current.end - pivot) * factor;
+ return clampWindow({ start, end }, bounds, minimumSpan(model));
+}
+
+export function panWindow(
+ model: Pick,
+ view: TraceWindow | null,
+ direction: -1 | 1,
+): TraceWindow | null {
+ if (!view) {
+ return null;
+ }
+ const shift = (view.end - view.start) * PAN_STEP * direction;
+ return clampWindow(
+ { start: view.start + shift, end: view.end + shift },
+ { start: model.start, end: model.end },
+ minimumSpan(model),
+ );
+}
+
+export function minimumSpan(model: Pick): number {
+ return Math.max((model.end - model.start) / 1000, 1);
+}
diff --git a/client/src/components/Chat/Trace/store.ts b/client/src/components/Chat/Trace/store.ts
new file mode 100644
index 00000000000..b0318acfd20
--- /dev/null
+++ b/client/src/components/Chat/Trace/store.ts
@@ -0,0 +1,8 @@
+import { atom } from 'jotai';
+
+/**
+ * The conversation whose trace covers the chat surface, or `null` when the chat
+ * shows. Keyed by conversation so a trace never survives into another chat.
+ * Memory-only: a reload returns to the conversation.
+ */
+export const traceViewerConversationAtom = atom(null);
diff --git a/client/src/components/Chat/Trace/useTraceControl.ts b/client/src/components/Chat/Trace/useTraceControl.ts
new file mode 100644
index 00000000000..93c4fa1f5ad
--- /dev/null
+++ b/client/src/components/Chat/Trace/useTraceControl.ts
@@ -0,0 +1,64 @@
+import { useRef, useEffect, useCallback } from 'react';
+import { useSetAtom } from 'jotai';
+import { useQueryClient } from '@tanstack/react-query';
+import { QueryKeys, Constants, resolveTraceViewerConfig } from 'librechat-data-provider';
+import type { TTraceViewerConfig } from 'librechat-data-provider';
+import { keepNewestTracePage, useConversationTraceAvailabilityQuery } from '~/data-provider';
+import { traceViewerConversationAtom } from './store';
+
+export type TraceControl = {
+ /** A persisted conversation of the user's with at least one readable trace. */
+ show: boolean;
+ open: () => void;
+};
+
+const UNPERSISTED_IDS = new Set(
+ [Constants.NEW_CONVO, Constants.PENDING_CONVO, Constants.SEARCH].map(String),
+);
+
+/**
+ * The trace entry point for the desktop button and the mobile overflow item, so
+ * both surfaces share one visibility rule. Availability is re-read after each
+ * run settles, which is when a newly sampled response can make it appear.
+ */
+export default function useTraceControl({
+ conversationId,
+ traceViewer,
+ isSubmitting,
+ enabled = true,
+}: {
+ conversationId?: string | null;
+ traceViewer?: TTraceViewerConfig;
+ isSubmitting: boolean;
+ enabled?: boolean;
+}): TraceControl {
+ const setTraceConversation = useSetAtom(traceViewerConversationAtom);
+ const persisted = conversationId != null && !UNPERSISTED_IDS.has(conversationId);
+ const eligible = enabled && persisted && resolveTraceViewerConfig(traceViewer).enabled;
+
+ const { data } = useConversationTraceAvailabilityQuery(conversationId ?? '', {
+ enabled: eligible && !isSubmitting,
+ });
+
+ /** A settled run adds a turn to the trace, so pages cached by an earlier open go stale now
+ * rather than when their stale time lapses. */
+ const queryClient = useQueryClient();
+ const wasSubmitting = useRef(isSubmitting);
+ useEffect(() => {
+ const settled = wasSubmitting.current && !isSubmitting;
+ wasSubmitting.current = isSubmitting;
+ if (settled && eligible && conversationId != null) {
+ keepNewestTracePage(queryClient, conversationId);
+ queryClient.invalidateQueries([QueryKeys.conversationTraceRecords, conversationId]);
+ queryClient.invalidateQueries([QueryKeys.conversationTraceRecord, conversationId]);
+ }
+ }, [isSubmitting, eligible, conversationId, queryClient]);
+
+ const open = useCallback(() => {
+ if (conversationId != null) {
+ setTraceConversation(conversationId);
+ }
+ }, [conversationId, setTraceConversation]);
+
+ return { show: eligible && data?.available === true, open };
+}
diff --git a/client/src/components/Chat/Trace/virtual.ts b/client/src/components/Chat/Trace/virtual.ts
new file mode 100644
index 00000000000..568d3a843d9
--- /dev/null
+++ b/client/src/components/Chat/Trace/virtual.ts
@@ -0,0 +1,54 @@
+import { useEffect, useState } from 'react';
+import type { RefObject } from 'react';
+
+export type RowWindow = { first: number; last: number };
+
+/**
+ * The slice of fixed-height rows inside a scroll container, plus overscan.
+ * Rows share one height, so the window is arithmetic rather than measured.
+ */
+export function useRowWindow(
+ scrollRef: RefObject,
+ count: number,
+ rowHeight: number,
+ { offset = 0, overscan = 8 }: { offset?: number; overscan?: number } = {},
+): RowWindow {
+ const [viewport, setViewport] = useState({ top: 0, height: 0 });
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) {
+ return;
+ }
+ let frame = 0;
+ const measure = () => {
+ frame = 0;
+ setViewport((current) =>
+ current.top === element.scrollTop && current.height === element.clientHeight
+ ? current
+ : { top: element.scrollTop, height: element.clientHeight },
+ );
+ };
+ const schedule = () => {
+ if (frame === 0) {
+ frame = requestAnimationFrame(measure);
+ }
+ };
+ measure();
+ element.addEventListener('scroll', schedule, { passive: true });
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
+ observer?.observe(element);
+ return () => {
+ element.removeEventListener('scroll', schedule);
+ observer?.disconnect();
+ if (frame !== 0) {
+ cancelAnimationFrame(frame);
+ }
+ };
+ }, [scrollRef]);
+
+ const visibleCount = Math.ceil((viewport.height || rowHeight * 20) / rowHeight);
+ const first = Math.max(0, Math.floor((viewport.top - offset) / rowHeight) - overscan);
+ const last = Math.min(count - 1, first + visibleCount + overscan * 2);
+ return { first, last };
+}
diff --git a/client/src/components/Conversations/ChatFilterMenu.tsx b/client/src/components/Conversations/ChatFilterMenu.tsx
new file mode 100644
index 00000000000..26a19da4846
--- /dev/null
+++ b/client/src/components/Conversations/ChatFilterMenu.tsx
@@ -0,0 +1,321 @@
+import { memo, useCallback, useId, useMemo, useState } from 'react';
+import * as Ariakit from '@ariakit/react';
+import { useAtomValue, useSetAtom } from 'jotai';
+import { PermissionTypes, Permissions } from 'librechat-data-provider';
+import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
+import { TooltipAnchor, buttonVariants, usePopoverZIndex } from '@librechat/client';
+import {
+ Archive,
+ ArrowDownAZ,
+ CalendarPlus,
+ Check,
+ Clock,
+ ListFilter,
+ MessagesSquare,
+ RotateCcw,
+} from 'lucide-react';
+import type { ReactNode } from 'react';
+import type { ChatFilterStatus, ChatSortDirection, ChatSortField } from './chatFilters';
+import type { TranslationKeys } from '~/hooks';
+import {
+ chatFilterCountAtom,
+ chatFilterStatusAtom,
+ chatFilterTagsAtom,
+ chatSortAtom,
+ isAlphabeticalSort,
+ resetChatFiltersAtom,
+ setChatFilterStatusAtom,
+ sortFieldsFor,
+ toggleChatFilterTagAtom,
+} from './chatFilters';
+import { useGetConversationTags } from '~/data-provider';
+import { useHasAccess, useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+const itemClassName =
+ 'flex w-full cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-2 text-sm text-text-primary outline-none data-[active-item]:bg-surface-hover md:py-1.5';
+
+const groupLabelClassName = 'px-2 pb-1 pt-1.5 text-xs font-medium text-text-secondary';
+
+/** Keeps every row's label on the same x, checked or not. */
+const CheckSlot = ({ checked }: { checked: boolean }) =>
+ checked ? (
+
+ ) : (
+
+ );
+
+type ChoiceProps = {
+ label: string;
+ icon: ReactNode;
+ checked: boolean;
+ onSelect: () => void;
+};
+
+/** One exclusive choice. `hideOnClick` stays off: adjusting two facets in a row is
+ * the common case, and a menu that closes on the first click makes that four clicks. */
+const Choice = ({ label, icon, checked, onSelect }: ChoiceProps) => (
+
+
+ {icon}
+
+ {label}
+
+
+);
+
+const STATUS_OPTIONS: Array<{ value: ChatFilterStatus; label: TranslationKeys; icon: ReactNode }> =
+ [
+ {
+ value: 'active',
+ label: 'com_ui_active_chats',
+ icon: ,
+ },
+ { value: 'archived', label: 'com_nav_archived_chats', icon: },
+ ];
+
+const SORT_OPTIONS: Record = {
+ updatedAt: { label: 'com_ui_sort_updated', icon: },
+ createdAt: { label: 'com_ui_sort_created', icon: },
+ title: { label: 'com_ui_sort_title', icon: },
+ archivedAt: { label: 'com_nav_archive_created_at', icon: },
+};
+
+/** Bookmarks are their own query and their own permission, so they mount with the
+ * open menu rather than with the sidebar. */
+const BookmarkChoices = memo(() => {
+ const localize = useLocalize();
+ const tags = useAtomValue(chatFilterTagsAtom);
+ const toggleTag = useSetAtom(toggleChatFilterTagAtom);
+ const { data } = useGetConversationTags();
+
+ /** A bookmark no chat carries filters the list down to nothing. */
+ const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]);
+
+ if (bookmarks.length === 0) {
+ return (
+
+ {localize('com_ui_no_bookmarks_title')}
+
+ );
+ }
+
+ return (
+ <>
+ {bookmarks.map((bookmark) => {
+ const checked = tags.includes(bookmark.tag);
+ return (
+ toggleTag(bookmark.tag)}
+ className={itemClassName}
+ >
+
+ {checked ? (
+
+ ) : (
+
+ )}
+
+ {bookmark.tag}
+
+ {bookmark.count}
+
+
+ );
+ })}
+ >
+ );
+});
+
+BookmarkChoices.displayName = 'BookmarkChoices';
+
+/**
+ * Every way the chats list can be narrowed or reordered, in one menu beside the
+ * Chats heading: which chats (active or archived), what orders them, and which
+ * bookmarks they must carry. It replaces the bookmark-only control that used to sit
+ * next to the search field, so filtering lives where the list it filters is labelled.
+ */
+const ChatFilterMenu = () => {
+ const localize = useLocalize();
+ const menuId = useId();
+ const zIndex = usePopoverZIndex();
+ const [isOpen, setIsOpen] = useState(false);
+
+ const status = useAtomValue(chatFilterStatusAtom);
+ const sort = useAtomValue(chatSortAtom);
+ const activeCount = useAtomValue(chatFilterCountAtom);
+ const setStatus = useSetAtom(setChatFilterStatusAtom);
+ const setSort = useSetAtom(chatSortAtom);
+ const resetFilters = useSetAtom(resetChatFiltersAtom);
+
+ const hasAccessToBookmarks = useHasAccess({
+ permissionType: PermissionTypes.BOOKMARKS,
+ permission: Permissions.USE,
+ });
+
+ const alphabetical = isAlphabeticalSort(sort.field);
+
+ /** Ascending means A first for titles and oldest first for instants, so the same
+ * two values need two different pairs of labels. */
+ const directionOptions = useMemo>(
+ () =>
+ alphabetical
+ ? [
+ { value: 'asc', label: localize('com_ui_sort_a_z') },
+ { value: 'desc', label: localize('com_ui_sort_z_a') },
+ ]
+ : [
+ { value: 'desc', label: localize('com_ui_sort_newest') },
+ { value: 'asc', label: localize('com_ui_sort_oldest') },
+ ],
+ [alphabetical, localize],
+ );
+
+ const selectField = useCallback(
+ (field: ChatSortField) => {
+ /** Switching between an instant and a title keeps the direction's meaning rather
+ * than its value: newest-first reads as ZโA otherwise. */
+ const keepsMeaning = isAlphabeticalSort(field) === isAlphabeticalSort(sort.field);
+ const flipped = sort.direction === 'desc' ? 'asc' : 'desc';
+ const direction = keepsMeaning ? sort.direction : flipped;
+ setSort({ field, direction });
+ },
+ [setSort, sort.direction, sort.field],
+ );
+
+ const triggerLabel =
+ activeCount > 0
+ ? localize('com_ui_filters_active', { count: activeCount })
+ : localize('com_ui_filter_and_sort_chats');
+
+ return (
+
+
+ );
+};
+
+export default memo(ChatFilterMenu);
diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx
index c6feda3b874..0bf3e1a3ac4 100644
--- a/client/src/components/Conversations/Conversations.tsx
+++ b/client/src/components/Conversations/Conversations.tsx
@@ -3,11 +3,20 @@ import { useDrop } from 'react-dnd';
import throttle from 'lodash/throttle';
import { useRecoilValue } from 'recoil';
import { ChevronDown } from 'lucide-react';
+import { useAtomValue, useSetAtom } from 'jotai';
import { List, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { Spinner, useMediaQuery, buttonVariants } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import type { ConversationDragItem } from './dnd';
+import {
+ chatFilterCountAtom,
+ chatFilterTagsAtom,
+ chatSortAtom,
+ isAlphabeticalSort,
+ isArchivedChatViewAtom,
+ resetChatFiltersAtom,
+} from './chatFilters';
import {
CONVERSATION_DRAG_TYPE,
markExternalHover,
@@ -15,7 +24,7 @@ import {
useEffectiveProjectId,
} from './dnd';
import { useLocalize, TranslationKeys, useElementSize } from '~/hooks';
-import { groupConversationsByDate, cn } from '~/utils';
+import { groupConversations, cn } from '~/utils';
import { useActiveJobs } from '~/data-provider';
import Convo from './Convo';
import store from '~/store';
@@ -42,6 +51,12 @@ interface ConversationsProps {
setIsChatsExpanded: (expanded: boolean) => void;
/** Actions for the Chats header, alongside the Projects header's own. */
chatsHeaderTrailing?: ReactNode;
+ /** Whether another page exists, so an empty list can be told apart from an unpaged one. */
+ hasNextPage?: boolean;
+ /** Whether the initial conversations request failed without usable rows. */
+ isError?: boolean;
+ /** Re-run the conversations request from the error state. */
+ onRetry?: () => void;
}
interface MeasuredRowProps {
@@ -131,20 +146,24 @@ const ChatsHeader: FC = memo(({ isExpanded, onToggle, trailing
ChatsHeader.displayName = 'ChatsHeader';
-const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => {
- const localize = useLocalize();
- return (
-
- {localize(groupName as TranslationKeys) || groupName}
-
- );
-});
+const DateLabel: FC<{ groupName: string; isFirst?: boolean; isAlphabetical?: boolean }> = memo(
+ ({ groupName, isFirst, isAlphabetical = false }) => {
+ const localize = useLocalize();
+ const displayName = localize(groupName as TranslationKeys) || groupName;
+ return (
+
+ {displayName}
+
+ );
+ },
+);
DateLabel.displayName = 'DateLabel';
@@ -164,9 +183,17 @@ const Conversations: FC = ({
isChatsExpanded,
setIsChatsExpanded,
chatsHeaderTrailing,
+ hasNextPage = false,
+ isError = false,
+ onRetry,
}) => {
const localize = useLocalize();
const search = useRecoilValue(store.search);
+ const sort = useAtomValue(chatSortAtom);
+ const isArchivedView = useAtomValue(isArchivedChatViewAtom);
+ const activeFilterCount = useAtomValue(chatFilterCountAtom);
+ const filterTags = useAtomValue(chatFilterTagsAtom);
+ const resetFilters = useSetAtom(resetChatFiltersAtom);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
/* Dropping a project conversation on the Chats section files it back out of
* its project. Root-list chats already live here, so they are rejected. */
@@ -206,9 +233,17 @@ const Conversations: FC = ({
[rawConversations],
);
+ /** The pinned section above carries pins, so they stay out of these groups โ except in
+ * the archive, which that section does not cover: an archived pin would otherwise be
+ * absent from the sidebar entirely rather than merely further down it. */
const groupedConversations = useMemo(
- () => groupConversationsByDate(filteredConversations),
- [filteredConversations],
+ () =>
+ groupConversations(filteredConversations, {
+ field: sort.field,
+ direction: sort.direction,
+ includePinned: isArchivedView,
+ }),
+ [filteredConversations, isArchivedView, sort.direction, sort.field],
);
/* Pins are stripped from the date groups. An all-pin page leaves the
@@ -347,7 +382,11 @@ const Conversations: FC = ({
if (item.type === 'header') {
return (
-
+
);
}
@@ -369,7 +408,7 @@ const Conversations: FC = ({
return null;
},
- [cache, flattenedItems, moveToTop, toggleNav, activeJobIds],
+ [cache, flattenedItems, moveToTop, toggleNav, activeJobIds, sort.field],
);
const getRowHeight = useCallback(
@@ -390,6 +429,102 @@ const Conversations: FC = ({
},
[flattenedItems.length, throttledLoadMore],
);
+ const isListError =
+ isChatsExpanded &&
+ isError &&
+ !isLoading &&
+ !isSearchLoading &&
+ filteredConversations.length === 0;
+
+ /** A list that came back empty is a dead end the user has to be able to leave: say why
+ * it is empty and offer the way back. A drained page can still contain only pinned rows,
+ * which render in PinnedSection and do not make the account empty. */
+ const hasUnfilteredRows =
+ !search.query && filterTags.length === 0 && !isArchivedView && filteredConversations.length > 0;
+ const isEmpty =
+ isChatsExpanded &&
+ !isLoading &&
+ !isSearchLoading &&
+ !isListError &&
+ !hasNextPage &&
+ groupedConversations.length === 0 &&
+ !hasUnfilteredRows;
+
+ let emptyLabel: TranslationKeys = 'com_ui_no_chats';
+ if (search.query) {
+ emptyLabel = 'com_ui_no_search_results';
+ } else if (filterTags.length > 0) {
+ emptyLabel = 'com_ui_no_chats_match_filters';
+ } else if (isArchivedView) {
+ emptyLabel = 'com_ui_no_archived_chats';
+ }
+
+ let body: ReactNode = (
+
+
+
+ );
+ if (isSearchLoading) {
+ body = (
+
+
+ {localize('com_ui_loading')}
+
+ );
+ } else if (isListError) {
+ body = (
+
+ {localize('com_ui_chats_load_error')}
+ {onRetry && (
+
+ {localize('com_ui_retry')}
+
+ )}
+
+ );
+ } else if (isEmpty) {
+ body = (
+
+ {localize(emptyLabel)}
+ {activeFilterCount > 0 && (
+ resetFilters()}
+ className="rounded-lg px-2 py-1 text-sm text-text-primary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
+ >
+ {localize('com_ui_clear_filters')}
+
+ )}
+
+ );
+ }
return (
= ({
highlight={isDropOver && canDrop}
/>
- {isSearchLoading ? (
-
-
- {localize('com_ui_loading')}
-
- ) : (
-
-
-
- )}
+ {body}
);
};
diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx
index 59329f9ae34..1dfbb4fc088 100644
--- a/client/src/components/Conversations/Convo.tsx
+++ b/client/src/components/Conversations/Convo.tsx
@@ -272,6 +272,9 @@ function Conversation({
const convoOptionsProps = {
title,
isPinned: conversation.pinned,
+ /* The row's own state, not the list's: the sidebar shows unarchived pins beside an
+ archived list, and the previous page's rows stay on screen while the next loads. */
+ isArchived: conversation.isArchived === true,
retainView,
renameHandler: handleRename,
isActiveConvo,
diff --git a/client/src/components/Conversations/ConvoActions.tsx b/client/src/components/Conversations/ConvoActions.tsx
index b4578bc3a61..9c1131e6005 100644
--- a/client/src/components/Conversations/ConvoActions.tsx
+++ b/client/src/components/Conversations/ConvoActions.tsx
@@ -10,6 +10,7 @@ export type ConvoActionsProps = {
chatProjectId?: string | null;
title: string | null;
isPinned?: boolean;
+ isArchived?: boolean;
isActiveConvo: boolean;
isShiftHeld?: boolean;
isPopoverActive: boolean;
diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
index fce713aaa82..b0d4951f6cb 100644
--- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
+++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
@@ -3,24 +3,25 @@ import * as Ariakit from '@ariakit/react';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { QueryKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
+import {
+ DropdownPopup,
+ Spinner,
+ buttonVariants,
+ useToastContext,
+ useMediaQuery,
+} from '@librechat/client';
import {
Ellipsis,
Share2,
CopyPlus,
Archive,
+ ArchiveRestore,
FolderInput,
FolderX,
Pen,
Pin,
Trash,
} from 'lucide-react';
-import {
- DropdownPopup,
- Spinner,
- buttonVariants,
- useToastContext,
- useMediaQuery,
-} from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { MouseEvent } from 'react';
import {
@@ -32,18 +33,30 @@ import {
usePinConversationMutation,
} from '~/data-provider';
import { useHasAccess, useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
+import { useChatContext, useLiveAnnouncer } from '~/Providers';
import { NotificationSeverity } from '~/common';
-import { useChatContext } from '~/Providers';
import ProjectButton from './ProjectButton';
import DeleteButton from './DeleteButton';
import ShareButton from './ShareButton';
import { cn } from '~/utils';
+/** The overflow menu and the shift-held quick action show the same archive control in two
+ * sizes, and must never disagree about which direction it moves the conversation. */
+function renderArchiveIcon(isLoading: boolean, isArchived: boolean, className: string) {
+ if (isLoading) {
+ return ;
+ }
+ if (isArchived) {
+ return ;
+ }
+ return ;
+}
function ConvoOptions({
conversationId,
chatProjectId,
title,
isPinned = false,
+ isArchived = false,
retainView,
renameHandler,
isPopoverActive,
@@ -55,6 +68,8 @@ function ConvoOptions({
chatProjectId?: string | null;
title: string | null;
isPinned?: boolean;
+ /** This row's own archive state, which the sidebar filter does not stand in for. */
+ isArchived?: boolean;
retainView: () => void;
renameHandler: (e: MouseEvent) => void;
isPopoverActive: boolean;
@@ -65,13 +80,21 @@ function ConvoOptions({
const localize = useLocalize();
const queryClient = useQueryClient();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
- const { index } = useChatContext();
+ const { index, setConversation } = useChatContext();
const { data: startupConfig } = useGetStartupConfig();
const { navigateToConvo } = useNavigateToConvo(index);
const { showToast } = useToastContext();
+ /* Archiving or restoring removes the row from the list that held it, unmounting this
+ menu: the announcement has to come from a live region that outlives the row. */
+ const { announcePolite } = useLiveAnnouncer();
const navigate = useNavigate();
const { conversationId: currentConvoId } = useParams();
+ /* A mutation callback outlives the click that made it: the route it should compare
+ against is whichever chat is open when the request resolves, not the one that was
+ open when the menu item was pressed. */
+ const openConvoIdRef = useRef(currentConvoId);
+ openConvoIdRef.current = currentConvoId;
const { newConversation } = useNewConvo();
const menuId = useId();
@@ -82,7 +105,6 @@ function ConvoOptions({
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showProjectDialog, setShowProjectDialog] = useState(false);
- const [announcement, setAnnouncement] = useState('');
const canCreateSharedLinks = useHasAccess({
permissionType: PermissionTypes.SHARED_LINKS,
@@ -206,14 +228,21 @@ function ConvoOptions({
}
archiveConvoMutation.mutate(
- { conversationId: convoId, isArchived: true },
+ { conversationId: convoId, isArchived: !isArchived },
{
onSuccess: () => {
- setAnnouncement(localize('com_ui_convo_archived'));
- setTimeout(() => {
- setAnnouncement('');
- }, 10000);
- if (currentConvoId === convoId || currentConvoId === 'new') {
+ /* The request outlives the row: by the time it resolves the user may have opened
+ another chat, so the open conversation is identified at commit time rather than
+ from the one this callback closed over. */
+ setConversation((prev) =>
+ prev?.conversationId === convoId ? { ...prev, isArchived: !isArchived } : prev,
+ );
+ announcePolite({
+ message: localize(isArchived ? 'com_ui_convo_unarchived' : 'com_ui_convo_archived'),
+ isStatus: true,
+ });
+ const openConvoId = openConvoIdRef.current;
+ if (!isArchived && (openConvoId === convoId || openConvoId === 'new')) {
newConversation();
navigate('/c/new', { replace: true });
}
@@ -222,7 +251,7 @@ function ConvoOptions({
},
onError: () => {
showToast({
- message: localize('com_ui_archive_error'),
+ message: localize(isArchived ? 'com_ui_unarchive_error' : 'com_ui_archive_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
@@ -232,12 +261,14 @@ function ConvoOptions({
},
[
conversationId,
- currentConvoId,
+ isArchived,
+ setConversation,
archiveConvoMutation,
navigate,
newConversation,
retainView,
setIsPopoverActive,
+ announcePolite,
showToast,
localize,
],
@@ -330,14 +361,10 @@ function ConvoOptions({
),
},
{
- label: localize('com_ui_archive'),
+ label: localize(isArchived ? 'com_ui_unarchive' : 'com_ui_archive'),
onClick: handleArchiveClick,
hideOnClick: false,
- icon: isArchiveLoading ? (
-
- ) : (
-
- ),
+ icon: renderArchiveIcon(isArchiveLoading, isArchived, 'icon-sm mr-2 text-text-primary'),
},
{
label: localize('com_ui_delete'),
@@ -360,6 +387,7 @@ function ConvoOptions({
renameHandler,
deleteHandler,
isArchiveLoading,
+ isArchived,
isDuplicateLoading,
handlePinClick,
handleArchiveClick,
@@ -387,16 +415,12 @@ function ConvoOptions({
return (
- {isArchiveLoading ? (
-
- ) : (
-
- )}
+ {renderArchiveIcon(isArchiveLoading, isArchived, 'icon-md text-text-secondary')}
-
- {announcement}
-
{
prevProps.title === nextProps.title &&
prevProps.chatProjectId === nextProps.chatProjectId &&
prevProps.isPinned === nextProps.isPinned &&
+ prevProps.isArchived === nextProps.isArchived &&
prevProps.isPopoverActive === nextProps.isPopoverActive &&
prevProps.isActiveConvo === nextProps.isActiveConvo &&
prevProps.isShiftHeld === nextProps.isShiftHeld
diff --git a/client/src/components/Conversations/ConvoOptions/__tests__/ConvoOptions.archive.test.tsx b/client/src/components/Conversations/ConvoOptions/__tests__/ConvoOptions.archive.test.tsx
new file mode 100644
index 00000000000..177f00e6866
--- /dev/null
+++ b/client/src/components/Conversations/ConvoOptions/__tests__/ConvoOptions.archive.test.tsx
@@ -0,0 +1,214 @@
+import React from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import '@testing-library/jest-dom';
+import ConvoOptions from '../ConvoOptions';
+
+const mockArchiveMutate = jest.fn();
+const mockNavigate = jest.fn();
+const mockNewConversation = jest.fn();
+const mockRetainView = jest.fn();
+const mockSetIsPopoverActive = jest.fn();
+const mockAnnouncePolite = jest.fn();
+const mockSetConversation = jest.fn();
+
+jest.mock('@ariakit/react', () => ({
+ MenuButton: jest
+ .requireActual('react')
+ .forwardRef(
+ (
+ { children, ...props }: React.ButtonHTMLAttributes,
+ ref: React.ForwardedRef,
+ ) => (
+
+ {children}
+
+ ),
+ ),
+}));
+jest.mock('~/utils', () => ({
+ cn: (...classes: Array) => classes.filter(Boolean).join(' '),
+}));
+jest.mock('librechat-data-provider', () => ({
+ QueryKeys: { messages: 'messages' },
+ PermissionTypes: { SHARED_LINKS: 'shared_links' },
+ Permissions: { CREATE: 'create' },
+}));
+jest.mock('@librechat/client', () => ({
+ DropdownPopup: ({
+ trigger,
+ items,
+ }: {
+ trigger: React.ReactNode;
+ items: Array<{ label: string; onClick: () => void }>;
+ }) => (
+ <>
+ {trigger}
+ {items
+ .filter((item) => item.label === 'com_ui_archive' || item.label === 'com_ui_unarchive')
+ .map((item) => (
+
+ {item.label}
+
+ ))}
+ >
+ ),
+ Spinner: () => ,
+ buttonVariants: () => '',
+ useToastContext: () => ({ showToast: jest.fn() }),
+ useMediaQuery: () => false,
+}));
+
+jest.mock('~/data-provider', () => ({
+ useDuplicateConversationMutation: () => ({ mutate: jest.fn(), isLoading: false }),
+ useAssignConversationToProjectMutation: () => ({ mutate: jest.fn(), isLoading: false }),
+ useDeleteConversationMutation: () => ({ mutate: jest.fn(), isLoading: false }),
+ useGetStartupConfig: () => ({ data: { sharedLinksEnabled: false } }),
+ useArchiveConvoMutation: () => ({ mutate: mockArchiveMutate, isLoading: false }),
+ usePinConversationMutation: () => ({ mutate: jest.fn(), isLoading: false }),
+}));
+
+jest.mock('~/hooks', () => ({
+ useHasAccess: () => true,
+ useLocalize: () => (key: string) => key,
+ useNavigateToConvo: () => ({ navigateToConvo: jest.fn() }),
+ useNewConvo: () => ({ newConversation: mockNewConversation }),
+}));
+
+jest.mock('~/Providers', () => ({
+ useChatContext: () => ({
+ index: 0,
+ conversation: { conversationId: 'conversation-1', isArchived: true },
+ setConversation: mockSetConversation,
+ }),
+ useLiveAnnouncer: () => ({ announcePolite: mockAnnouncePolite }),
+}));
+
+let mockOpenRouteConvoId: string | undefined = 'conversation-1';
+
+jest.mock('react-router-dom', () => {
+ const actual = jest.requireActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ useParams: () => ({ conversationId: mockOpenRouteConvoId }),
+ };
+});
+
+jest.mock('../ProjectButton', () => () => null);
+jest.mock('../DeleteButton', () => () => null);
+jest.mock('../ShareButton', () => () => null);
+
+const renderOptions = (isArchived: boolean, isActiveConvo = false) => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const tree = (active: boolean) => (
+
+
+
+
+ }
+ />
+
+
+
+ );
+
+ const { rerender } = render(tree(isActiveConvo));
+ /** Opening another chat moves the route and drops this row's active state. */
+ return { openAnotherChat: () => rerender(tree(false)) };
+};
+
+describe('ConvoOptions archive action', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ mockArchiveMutate.mockReset();
+ mockNavigate.mockReset();
+ mockNewConversation.mockReset();
+ mockRetainView.mockReset();
+ mockSetIsPopoverActive.mockReset();
+ mockAnnouncePolite.mockReset();
+ mockSetConversation.mockReset();
+ mockOpenRouteConvoId = 'conversation-1';
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ /** The row's own state decides the direction: an unarchived pin stays listed beside the
+ * archive, and the previous page's rows outlive a status switch, so a list-level flag
+ * would offer to restore a conversation that was never archived. */
+ it('restores the open archived conversation state without navigating away from it', () => {
+ renderOptions(true);
+
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_unarchive' }));
+
+ expect(mockArchiveMutate).toHaveBeenCalledWith(
+ { conversationId: 'conversation-1', isArchived: false },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ const [, callbacks] = mockArchiveMutate.mock.calls[0];
+ act(() => callbacks.onSuccess());
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(mockNewConversation).not.toHaveBeenCalled();
+ /* The restored row leaves the archived list, unmounting this menu, so the message has
+ to reach the app-level live region rather than one inside the row. */
+ expect(mockAnnouncePolite).toHaveBeenCalledWith({
+ message: 'com_ui_convo_unarchived',
+ isStatus: true,
+ });
+ const [updater] = mockSetConversation.mock.calls[0];
+ expect(updater({ conversationId: 'conversation-1', isArchived: true })).toEqual({
+ conversationId: 'conversation-1',
+ isArchived: false,
+ });
+ /* The request outlives the row: if another chat is open by the time it resolves, the
+ restore must not replace it with the one the callback was created for. */
+ const otherOpen = { conversationId: 'conversation-2', isArchived: false };
+ expect(updater(otherOpen)).toBe(otherOpen);
+ });
+
+ it('archives an unarchived conversation and leaves the one it just hid', () => {
+ renderOptions(false);
+
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
+
+ expect(mockArchiveMutate).toHaveBeenCalledWith(
+ { conversationId: 'conversation-1', isArchived: true },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ const [, callbacks] = mockArchiveMutate.mock.calls[0];
+ act(() => callbacks.onSuccess());
+ expect(mockNewConversation).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('/c/new', { replace: true });
+ });
+
+ /** Archiving the open chat opens a new one in its place, but the request outlives the
+ * click: if the user has opened another chat by then, that is the one on screen and it
+ * must be left alone. */
+ it('leaves a newer selection alone when the archive resolves', () => {
+ const { openAnotherChat } = renderOptions(false, true);
+
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_archive' }));
+ mockOpenRouteConvoId = 'conversation-2';
+ act(() => openAnotherChat());
+
+ const [, callbacks] = mockArchiveMutate.mock.calls[0];
+ act(() => callbacks.onSuccess());
+ expect(mockNewConversation).not.toHaveBeenCalled();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+});
diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx
index f60ec5548b9..7768882dcee 100644
--- a/client/src/components/Conversations/__tests__/Conversations.test.tsx
+++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx
@@ -1,5 +1,5 @@
import React, { createRef } from 'react';
-import { render } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import { DndProvider } from 'react-dnd';
@@ -79,7 +79,7 @@ jest.mock('~/data-provider', () => ({
}));
jest.mock('~/utils', () => ({
- groupConversationsByDate: () => [],
+ groupConversations: () => [],
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
@@ -150,11 +150,17 @@ describe('Conversations: all-pin pages still paginate', () => {
loadMoreConversations,
isChatsExpanded = true,
isLoading = false,
+ isError = false,
+ onRetry,
+ hasNextPage = false,
}: {
conversations: TConversation[];
loadMoreConversations: () => void;
isChatsExpanded?: boolean;
isLoading?: boolean;
+ isError?: boolean;
+ onRetry?: () => void;
+ hasNextPage?: boolean;
}) =>
render(
@@ -168,13 +174,30 @@ describe('Conversations: all-pin pages still paginate', () => {
loadMoreConversations={loadMoreConversations}
isLoading={isLoading}
isSearchLoading={false}
+ isError={isError}
+ onRetry={onRetry}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={jest.fn()}
+ hasNextPage={hasNextPage}
/>
,
);
+ it('renders a retryable load error instead of the empty state', () => {
+ const onRetry = jest.fn();
+ renderList({
+ conversations: [],
+ loadMoreConversations: jest.fn(),
+ isError: true,
+ onRetry,
+ });
+
+ expect(screen.getByTestId('convo-list-error')).toHaveTextContent('com_ui_chats_load_error');
+ expect(screen.queryByTestId('convo-list-empty')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
it('requests another page when grouping leaves the chats list empty', () => {
const loadMoreConversations = jest.fn();
@@ -182,6 +205,16 @@ describe('Conversations: all-pin pages still paginate', () => {
expect(loadMoreConversations).toHaveBeenCalled();
});
+ it('does not show no chats when a drained unfiltered page contains only pinned rows', () => {
+ renderList({
+ conversations: [pinnedConvo],
+ loadMoreConversations: jest.fn(),
+ hasNextPage: false,
+ });
+
+ expect(screen.queryByText('com_ui_no_chats')).not.toBeInTheDocument();
+ });
+
it('does not request another page while chats are collapsed', () => {
const loadMoreConversations = jest.fn();
renderList({
diff --git a/client/src/components/Conversations/__tests__/DateLabel.test.tsx b/client/src/components/Conversations/__tests__/DateLabel.test.tsx
index ccd9bc41261..fc5fdb80a59 100644
--- a/client/src/components/Conversations/__tests__/DateLabel.test.tsx
+++ b/client/src/components/Conversations/__tests__/DateLabel.test.tsx
@@ -7,6 +7,7 @@ jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, params?: Record) => {
const translations: Record = {
com_a11y_chats_date_section: `Chats from ${params?.date ?? ''}`,
+ com_a11y_chats_alpha_section: `Chats with titles starting with ${params?.letter ?? ''}`,
com_ui_date_today: 'Today',
com_ui_date_yesterday: 'Yesterday',
com_ui_date_previous_7_days: 'Previous 7 days',
@@ -21,6 +22,16 @@ describe('DateLabel', () => {
expect(screen.getByRole('heading', { level: 2, name: 'Chats from Today' })).toBeInTheDocument();
});
+ it('uses the alphabetical accessible name for title groups', () => {
+ render( );
+ expect(
+ screen.getByRole('heading', {
+ level: 2,
+ name: 'Chats with titles starting with A',
+ }),
+ ).toBeInTheDocument();
+ });
+
it('renders visible text as the localized group name', () => {
render( );
expect(screen.getByText('Today')).toBeInTheDocument();
diff --git a/client/src/components/Conversations/chatFilters.spec.ts b/client/src/components/Conversations/chatFilters.spec.ts
new file mode 100644
index 00000000000..c6ed088a190
--- /dev/null
+++ b/client/src/components/Conversations/chatFilters.spec.ts
@@ -0,0 +1,22 @@
+import { createStore } from 'jotai';
+import {
+ DEFAULT_CHAT_SORT,
+ chatFilterStatusAtom,
+ chatSortAtom,
+ setChatFilterStatusAtom,
+} from './chatFilters';
+
+describe('chat filter status changes', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('normalizes a malformed persisted sort before switching status', () => {
+ localStorage.setItem('chatListSort', 'null');
+ const store = createStore();
+
+ expect(() => store.set(setChatFilterStatusAtom, 'archived')).not.toThrow();
+ expect(store.get(chatFilterStatusAtom)).toBe('archived');
+ expect(store.get(chatSortAtom)).toEqual(DEFAULT_CHAT_SORT);
+ });
+});
diff --git a/client/src/components/Conversations/chatFilters.ts b/client/src/components/Conversations/chatFilters.ts
new file mode 100644
index 00000000000..2afe31095e4
--- /dev/null
+++ b/client/src/components/Conversations/chatFilters.ts
@@ -0,0 +1,113 @@
+import { atom } from 'jotai';
+import type { ConversationListParams } from 'librechat-data-provider';
+import { createStorageAtom } from '~/store/jotai-utils';
+
+/** Which slice of the user's chats the sidebar list is showing. */
+export type ChatFilterStatus = 'active' | 'archived';
+
+export type ChatSortField = NonNullable;
+export type ChatSortDirection = NonNullable;
+
+export type ChatSort = {
+ field: ChatSortField;
+ direction: ChatSortDirection;
+};
+
+export const DEFAULT_CHAT_SORT: ChatSort = { field: 'updatedAt', direction: 'desc' };
+
+/** Sort fields the list offers, per view. `archivedAt` only means something once the
+ * archive is what is on screen, and the server rejects any field outside this set. */
+export const ACTIVE_SORT_FIELDS: readonly ChatSortField[] = ['updatedAt', 'createdAt', 'title'];
+export const ARCHIVED_SORT_FIELDS: readonly ChatSortField[] = [
+ 'archivedAt',
+ 'updatedAt',
+ 'createdAt',
+ 'title',
+];
+
+export const sortFieldsFor = (status: ChatFilterStatus): readonly ChatSortField[] =>
+ status === 'archived' ? ARCHIVED_SORT_FIELDS : ACTIVE_SORT_FIELDS;
+
+/** `title` orders strings; every other field orders instants. The direction control
+ * and the list's own grouping both read this. */
+export const isAlphabeticalSort = (field: ChatSortField): boolean => field === 'title';
+
+/**
+ * A persisted sort survives a rename of these fields, a downgrade, and hand-editing,
+ * and an unknown field reaches Mongo as a rejected sort โ a failed list, not a
+ * fallback. Anything unrecognized, or valid only for the view that is not on screen,
+ * reads as the default instead.
+ */
+const sanitizeSort = (value: unknown, status: ChatFilterStatus): ChatSort => {
+ if (value == null || typeof value !== 'object') {
+ return DEFAULT_CHAT_SORT;
+ }
+ const { field, direction } = value as Partial;
+ if (field == null || !sortFieldsFor(status).includes(field)) {
+ return DEFAULT_CHAT_SORT;
+ }
+ return { field, direction: direction === 'asc' ? 'asc' : 'desc' };
+};
+
+/** Which chats are listed. Deliberately not persisted: a filter that hides chats
+ * should not greet the user on the next visit. */
+export const chatFilterStatusAtom = atom('active');
+
+/** Bookmark (tag) filter, OR-matched by the server. Session-scoped, like the status. */
+export const chatFilterTagsAtom = atom([]);
+
+/** Ordering is a preference rather than a filter, so it is the one part that is kept. */
+const storedChatSortAtom = createStorageAtom('chatListSort', DEFAULT_CHAT_SORT);
+
+export const chatSortAtom = atom(
+ (get) => sanitizeSort(get(storedChatSortAtom), get(chatFilterStatusAtom)),
+ (get, set, next: ChatSort) => {
+ set(storedChatSortAtom, sanitizeSort(next, get(chatFilterStatusAtom)));
+ },
+);
+
+/** Read by conversation rows to offer restoring instead of archiving. */
+export const isArchivedChatViewAtom = atom((get) => get(chatFilterStatusAtom) === 'archived');
+
+/** Drives the trigger's badge: how many choices differ from the default list. */
+export const chatFilterCountAtom = atom((get) => {
+ const sort = get(chatSortAtom);
+ let count = get(chatFilterTagsAtom).length;
+ if (get(chatFilterStatusAtom) !== 'active') {
+ count += 1;
+ }
+ if (sort.field !== DEFAULT_CHAT_SORT.field || sort.direction !== DEFAULT_CHAT_SORT.direction) {
+ count += 1;
+ }
+ return count;
+});
+
+export const resetChatFiltersAtom = atom(null, (_get, set) => {
+ set(chatFilterStatusAtom, 'active');
+ set(chatFilterTagsAtom, []);
+ set(storedChatSortAtom, DEFAULT_CHAT_SORT);
+});
+
+/**
+ * Switching views re-scopes the sort: `archivedAt` cannot order the active list, and
+ * leaving it set would send the server a field it refuses.
+ */
+export const setChatFilterStatusAtom = atom(null, (get, set, status: ChatFilterStatus) => {
+ set(chatFilterStatusAtom, status);
+ const sort = sanitizeSort(get(storedChatSortAtom), status);
+ set(storedChatSortAtom, sort);
+});
+
+/** Reset account-scoped filters without discarding the per-device sort preference. */
+export const resetChatFilterSessionAtom = atom(null, (_get, set) => {
+ set(chatFilterStatusAtom, 'active');
+ set(chatFilterTagsAtom, []);
+});
+
+export const toggleChatFilterTagAtom = atom(null, (get, set, tag: string) => {
+ const tags = get(chatFilterTagsAtom);
+ set(
+ chatFilterTagsAtom,
+ tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag],
+ );
+});
diff --git a/client/src/components/Conversations/utils.ts b/client/src/components/Conversations/utils.ts
index 281acc11bd6..f66141aa6d6 100644
--- a/client/src/components/Conversations/utils.ts
+++ b/client/src/components/Conversations/utils.ts
@@ -35,6 +35,7 @@ export function areConversationListItemFieldsEqual(
prevConversation.title === nextConversation.title &&
prevConversation.chatProjectId === nextConversation.chatProjectId &&
prevConversation.pinned === nextConversation.pinned &&
+ prevConversation.isArchived === nextConversation.isArchived &&
prevConversation.isShared === nextConversation.isShared &&
prevConversation.createdAt === nextConversation.createdAt &&
prevConversation.updatedAt === nextConversation.updatedAt
diff --git a/client/src/components/Messages/Content/Error.tsx b/client/src/components/Messages/Content/Error.tsx
index 3f9f50bc4c2..7577eef1860 100644
--- a/client/src/components/Messages/Content/Error.tsx
+++ b/client/src/components/Messages/Content/Error.tsx
@@ -75,12 +75,17 @@ type TCodeWorkspaceError = {
reason?: string;
};
+type TUpstreamModelError = {
+ status?: number;
+};
+
const codeWorkspaceErrorKeys: Record = {
required: 'com_error_code_workspace_required',
invalid: 'com_error_code_workspace_invalid',
worker_unavailable: 'com_error_code_workspace_worker_unavailable',
unsupported: 'com_error_code_workspace_unsupported',
missing: 'com_error_code_workspace_missing',
+ locked: 'com_error_code_workspace_locked',
};
/** Why a manual compaction could not run, keyed by the SDK's skip reason. */
@@ -152,6 +157,10 @@ const errorMessages = {
[ErrorTypes.STREAM_EXPIRED]: 'com_error_stream_expired',
[ErrorTypes.MODEL_NOT_FOUND]: langChainErrorKeys.MODEL_NOT_FOUND,
[ErrorTypes.MODEL_RATE_LIMIT]: langChainErrorKeys.MODEL_RATE_LIMIT,
+ [ErrorTypes.UPSTREAM_MODEL_ERROR]: (json: TUpstreamModelError, localize: LocalizeFunction) =>
+ typeof json.status === 'number'
+ ? localize('com_error_upstream_model_status', { 0: json.status })
+ : localize('com_error_upstream_model'),
[ErrorTypes.COMPACTION_FAILED]: 'com_error_compaction_failed',
[ErrorTypes.COMPACTION_SKIPPED]: (json: TCompactionSkipped, localize: LocalizeFunction) =>
localize(compactionSkippedKeys[json.reason ?? ''] ?? 'com_error_compaction_failed'),
diff --git a/client/src/components/Messages/Content/__tests__/Error.spec.tsx b/client/src/components/Messages/Content/__tests__/Error.spec.tsx
index 25f47f48264..da800ed60f3 100644
--- a/client/src/components/Messages/Content/__tests__/Error.spec.tsx
+++ b/client/src/components/Messages/Content/__tests__/Error.spec.tsx
@@ -24,6 +24,7 @@ jest.mock('~/hooks', () => ({
}));
const catalog = translation as Record;
+const upstreamFallback = 'The model provider could not complete this request.';
describe('Error โ typed provider errors', () => {
it('renders the localized copy for a rejected Google video', () => {
@@ -59,6 +60,39 @@ describe('Error โ typed provider errors', () => {
expect(screen.getByText(catalog[key])).toBeInTheDocument();
});
+ it('localizes an upstream model failure without a status', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(catalog.com_error_upstream_model)).toBeInTheDocument();
+ });
+
+ it('localizes an upstream model failure with its safe status', () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText(catalog.com_error_upstream_model_status.replace('{{0}}', '529')),
+ ).toBeInTheDocument();
+ });
+
+ it('keeps the wire fallback readable for a client that does not know the typed error', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(new RegExp(upstreamFallback, 'i'))).toBeInTheDocument();
+ });
+
it.each([
['required', 'com_error_code_workspace_required'],
['invalid', 'com_error_code_workspace_invalid'],
diff --git a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx
deleted file mode 100644
index cb9ec00b8c2..00000000000
--- a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import { useState, useId, useMemo, useCallback, memo } from 'react';
-import * as Ariakit from '@ariakit/react';
-import { CrossCircledIcon } from '@radix-ui/react-icons';
-import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
-import { DropdownPopup, TooltipAnchor, buttonVariants } from '@librechat/client';
-import type { FC } from 'react';
-import type * as t from '~/common';
-import { useGetConversationTags } from '~/data-provider';
-import { useLocalize } from '~/hooks';
-import { cn } from '~/utils';
-
-type BookmarkNavProps = {
- tags: string[];
- setTags: (tags: string[]) => void;
- /** Sizes the trigger to the search bar it shares a row with; otherwise it keeps
- * the compact section-heading size used beside the Chats heading on mobile. */
- matchSearchBar?: boolean;
-};
-
-const BookmarkNav: FC = ({ tags, setTags, matchSearchBar }: BookmarkNavProps) => {
- const localize = useLocalize();
- const menuId = useId();
- const [isMenuOpen, setIsMenuOpen] = useState(false);
- const { data } = useGetConversationTags();
-
- const label = useMemo(
- () => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
- [tags, localize],
- );
-
- const buttonAriaLabel = useMemo(() => {
- if (tags.length === 0) {
- return localize('com_ui_bookmarks');
- }
- return localize('com_ui_bookmarks_count_selected', { count: tags.length });
- }, [tags.length, localize]);
-
- const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]);
-
- const handleTagClick = useCallback(
- (tag: string) => {
- if (tags.includes(tag)) {
- setTags(tags.filter((t) => t !== tag));
- } else {
- setTags([...tags, tag]);
- }
- },
- [tags, setTags],
- );
-
- const handleClear = useCallback(() => {
- setTags([]);
- }, [setTags]);
-
- const dropdownItems: t.MenuItemProps[] = useMemo(() => {
- const items: t.MenuItemProps[] = [
- {
- id: 'clear-all',
- label: localize('com_ui_clear_all'),
- icon: ,
- hideOnClick: false,
- onClick: handleClear,
- },
- ];
-
- if (bookmarks.length === 0) {
- items.push({
- id: 'no-bookmarks',
- label: localize('com_ui_no_bookmarks'),
- icon: '๐ค',
- disabled: true,
- });
- } else {
- for (const bookmark of bookmarks) {
- const isSelected = tags.includes(bookmark.tag);
- items.push({
- id: bookmark.tag,
- label: bookmark.tag,
- hideOnClick: false,
- icon: isSelected ? (
-
- ) : (
-
- ),
- onClick: () => handleTagClick(bookmark.tag),
- ariaChecked: isSelected,
- });
- }
- }
-
- return items;
- }, [bookmarks, tags, localize, handleTagClick, handleClear]);
-
- return (
-