From 8fda87f087797016da9b25312b9001b6c276f486 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 17:08:40 -0400 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=A7=BE=20perf:=20Load=20Tool-Call?= =?UTF-8?q?=20Previews=20and=20Fetch=20Full=20Parts=20on=20Demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long agent conversations shipped every tool output, argument string and subagent transcript on load, though the cards render collapsed: 12-18 MB of message JSON per chat, enough to crash iPhone Safari tabs. A client that adds ?toolPreviews=1 to the conversation load now receives bounded previews of settled tool calls (start and end of long outputs, JSON arguments and outputs kept valid with only their longest strings shortened, exit-status trailers kept whole, subagent transcripts omitted) with explicit markers and full lengths. Opening a card or a subagent panel fetches the stored part from a new owner-scoped endpoint, GET /api/messages/:conversationId/:messageId/parts/:partIndex, cached under its own React Query key. Clients that do not ask, exports and share links keep the full payload; bounds are configurable under toolCallPreviews in librechat.yaml, and a client-authored message that carries previews is refused rather than saved. --- .../__tests__/messages-content-edit.spec.js | 5 + .../routes/__tests__/messages-delete.spec.js | 5 + .../__tests__/messages-feedback.spec.js | 5 + .../messages-get-real-validation.spec.js | 5 + .../routes/__tests__/messages-get.spec.js | 5 + .../__tests__/messages-retention.spec.js | 5 + .../messages-subagent-thread.spec.js | 5 + .../__tests__/messages-tool-previews.spec.js | 318 ++++++++++++ .../routes/__tests__/messages-update.spec.js | 5 + api/server/routes/messages.js | 23 +- .../Chat/Messages/Content/AgentHandoff.tsx | 23 +- .../Chat/Messages/Content/ContentParts.tsx | 31 +- .../components/Chat/Messages/Content/Part.tsx | 2 + .../Messages/Content/Parts/SubagentCall.tsx | 5 + .../Chat/Messages/Content/Parts/command.ts | 6 +- .../Content/__tests__/hydration.test.tsx | 203 ++++++++ .../Chat/Messages/Content/disclosure.ts | 17 +- .../Chat/Messages/Content/hydration.tsx | 111 +++++ .../Subagents/SubagentThreadPanel.test.tsx | 107 ++++ .../Chat/Subagents/SubagentThreadPanel.tsx | 44 +- client/src/components/Chat/Subagents/state.ts | 3 + .../src/components/Chat/Trace/Inspector.tsx | 17 +- client/src/components/Chat/Trace/Viewer.tsx | 5 +- client/src/components/Chat/Trace/preview.ts | 14 +- .../ConvoOptions/ShareButton.tsx | 4 +- .../__tests__/ShareButton.test.tsx | 10 +- .../Messages/__tests__/queries.test.ts | 16 +- client/src/data-provider/Messages/queries.ts | 35 +- client/src/hooks/SSE/useResumableSSE.ts | 3 +- client/src/locales/en/translation.json | 2 + client/src/utils/approval.spec.ts | 26 + client/src/utils/tokens.spec.ts | 30 ++ client/src/utils/tokens.ts | 63 +-- librechat.example.yaml | 12 + packages/api/src/index.ts | 2 + packages/api/src/messages/index.ts | 2 + packages/api/src/messages/parts.spec.ts | 243 ++++++++++ packages/api/src/messages/parts.ts | 174 +++++++ packages/api/src/messages/previews.spec.ts | 349 +++++++++++++ packages/api/src/messages/previews.ts | 457 ++++++++++++++++++ packages/data-provider/src/api-endpoints.ts | 11 + packages/data-provider/src/config.ts | 21 + .../data-provider/src/createPayload.spec.ts | 55 +++ packages/data-provider/src/data-service.ts | 20 +- packages/data-provider/src/index.ts | 1 + packages/data-provider/src/keys.ts | 2 + packages/data-provider/src/previews.ts | 44 ++ packages/data-provider/src/types/agents.ts | 12 + packages/data-provider/src/types/queries.ts | 17 + packages/data-schemas/src/app/service.ts | 14 + packages/data-schemas/src/types/app.ts | 3 + 51 files changed, 2507 insertions(+), 90 deletions(-) create mode 100644 api/server/routes/__tests__/messages-tool-previews.spec.js create mode 100644 client/src/components/Chat/Messages/Content/__tests__/hydration.test.tsx create mode 100644 client/src/components/Chat/Messages/Content/hydration.tsx create mode 100644 packages/api/src/messages/index.ts create mode 100644 packages/api/src/messages/parts.spec.ts create mode 100644 packages/api/src/messages/parts.ts create mode 100644 packages/api/src/messages/previews.spec.ts create mode 100644 packages/api/src/messages/previews.ts create mode 100644 packages/data-provider/src/createPayload.spec.ts create mode 100644 packages/data-provider/src/previews.ts diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js index 48e2320c1f9..8f2987b2af2 100644 --- a/api/server/routes/__tests__/messages-content-edit.spec.js +++ b/api/server/routes/__tests__/messages-content-edit.spec.js @@ -7,6 +7,9 @@ jest.mock('@librechat/agents', () => ({ })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), unescapeLaTeX: jest.fn((value) => value), countTokens: jest.fn().mockResolvedValue(2), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), @@ -26,6 +29,8 @@ jest.mock('@librechat/api', () => ({ applyForcedRetention: jest.fn(), })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/routes/__tests__/messages-delete.spec.js b/api/server/routes/__tests__/messages-delete.spec.js index c8174256196..04e22a6698a 100644 --- a/api/server/routes/__tests__/messages-delete.spec.js +++ b/api/server/routes/__tests__/messages-delete.spec.js @@ -9,6 +9,9 @@ jest.mock('@librechat/agents', () => ({ })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), createContentFilter: jest.fn(() => (req, res, next) => next()), inspectContent: jest.fn(() => null), @@ -24,6 +27,8 @@ jest.mock('@librechat/api', () => ({ requireFeedbackEnabled: (req, res, next) => next(), })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js index 1174518fc5c..79d67c2f8f3 100644 --- a/api/server/routes/__tests__/messages-feedback.spec.js +++ b/api/server/routes/__tests__/messages-feedback.spec.js @@ -6,6 +6,9 @@ jest.mock('@librechat/agents', () => ({ })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), unescapeLaTeX: jest.fn((value) => value), countTokens: jest.fn().mockResolvedValue(10), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), @@ -18,6 +21,8 @@ jest.mock('@librechat/api', () => ({ applyForcedRetention: jest.fn(), })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/routes/__tests__/messages-get-real-validation.spec.js b/api/server/routes/__tests__/messages-get-real-validation.spec.js index a7d7e4a4d95..a8a7e922ff0 100644 --- a/api/server/routes/__tests__/messages-get-real-validation.spec.js +++ b/api/server/routes/__tests__/messages-get-real-validation.spec.js @@ -21,6 +21,9 @@ jest.mock('@librechat/agents', () => ({ })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), createContentFilter: jest.fn(() => (req, res, next) => next()), inspectContent: jest.fn(() => null), @@ -43,6 +46,8 @@ jest.mock('@librechat/api', () => ({ requireFeedbackEnabled: (req, res, next) => next(), })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js index de858ac410c..190598163d1 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 { + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), /** The real helper, without loading the rest of the package this suite mocks around. */ withoutTraceRefs: jest.requireActual('../../../../packages/api/src/langfuse/trace.ts') .withoutTraceRefs, @@ -126,6 +129,8 @@ jest.mock('@librechat/api', () => { }; }); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('@librechat/data-schemas', () => ({ diff --git a/api/server/routes/__tests__/messages-retention.spec.js b/api/server/routes/__tests__/messages-retention.spec.js index 9bbbc0ce9da..26740b6f1a9 100644 --- a/api/server/routes/__tests__/messages-retention.spec.js +++ b/api/server/routes/__tests__/messages-retention.spec.js @@ -7,6 +7,9 @@ jest.mock('@librechat/agents', () => ({ })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), stripPrivateMessageFields: jest.requireActual( '../../../../packages/api/src/protection/private/view', @@ -39,6 +42,8 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('~/models', () => ({ diff --git a/api/server/routes/__tests__/messages-subagent-thread.spec.js b/api/server/routes/__tests__/messages-subagent-thread.spec.js index 574a1435e3c..a76da38260f 100644 --- a/api/server/routes/__tests__/messages-subagent-thread.spec.js +++ b/api/server/routes/__tests__/messages-subagent-thread.spec.js @@ -6,6 +6,9 @@ const mockIsSubagentThreadWriteBlocked = jest.fn(); jest.mock('@librechat/agents', () => ({ sleep: jest.fn() })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), withoutTraceRefs: jest.fn((message) => message), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), createContentFilter: jest.fn(() => (_req, _res, next) => next()), @@ -52,6 +55,8 @@ jest.mock('~/models', () => ({ getMessagesByCursor: jest.fn(), })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({ isThreadActiveForOwner: jest.fn(), })); diff --git a/api/server/routes/__tests__/messages-tool-previews.spec.js b/api/server/routes/__tests__/messages-tool-previews.spec.js new file mode 100644 index 00000000000..e9108cb66bc --- /dev/null +++ b/api/server/routes/__tests__/messages-tool-previews.spec.js @@ -0,0 +1,318 @@ +const mongoose = require('mongoose'); +const express = require('express'); +const request = require('supertest'); +const { v4: uuidv4 } = require('uuid'); +const { MongoMemoryServer } = require('mongodb-memory-server'); + +const mockDb = { methods: null }; + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + countTokens: jest.fn().mockResolvedValue(10), + applyForcedRetention: jest.fn().mockResolvedValue(undefined), + createContentFilter: jest.fn(() => (_req, _res, next) => next()), + createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), + isSubagentThreadWriteBlocked: jest.fn().mockResolvedValue(false), + GenerationJobManager: { getJob: jest.fn().mockResolvedValue(null) }, +})); + +jest.mock('~/models', () => { + const methods = new Proxy( + {}, + { + get: + (_target, key) => + (...args) => + mockDb.methods[key](...args), + }, + ); + return methods; +}); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn().mockResolvedValue({ + toolCallPreviews: { enabled: true, outputChars: 512, argsChars: 512 }, + }), +})); + +jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); + +jest.mock('~/server/middleware', () => { + const validation = jest.requireActual('~/server/middleware/messageValidation'); + return { + ...validation, + requireJwtAuth: (_req, _res, next) => next(), + configMiddleware: (req, _res, next) => { + req.config = {}; + next(); + }, + }; +}); + +const OWNER = 'owner-user'; +const OTHER = 'other-user'; + +const fullOutput = `stdout:\n${'compiled module\n'.repeat(3_000)}[exit code: 0]`; +const fullArgs = JSON.stringify({ + command: 'npm run build', + intent: 'Build', + pad: 'p'.repeat(4_000), +}); +const transcript = [ + { type: 'text', text: 'child is reading' }, + { + type: 'tool_call', + tool_call: { + id: 'child_1', + name: 'read_file', + args: '{"path":"a"}', + output: 'z'.repeat(2_000), + }, + }, +]; + +describe('tool-call previews on the message routes', () => { + let mongoServer; + let app; + let conversationId; + const responseId = 'response-1'; + + const assistantContent = () => [ + { type: 'text', text: 'Building now', agentId: 'agent_a' }, + { + type: 'tool_call', + agentId: 'agent_a', + tool_call: { + id: 'call_bash', + type: 'tool_call', + name: 'bash_tool', + args: fullArgs, + output: fullOutput, + progress: 1, + }, + }, + { + type: 'tool_call', + agentId: 'agent_a', + tool_call: { + id: 'call_sub', + type: 'tool_call', + name: 'subagent', + args: '{"prompt":"look"}', + output: 'child summary', + progress: 1, + subagent_content: transcript, + }, + }, + ]; + + const storedResponse = async () => { + const [message] = await mockDb.methods.getMessages({ messageId: responseId, user: OWNER }); + return message; + }; + + const expectStoredContentIntact = async () => { + const message = await storedResponse(); + expect(message.content[1].tool_call.output).toBe(fullOutput); + expect(message.content[1].tool_call.args).toBe(fullArgs); + expect(message.content[1].tool_call).not.toHaveProperty('outputTruncated'); + expect(message.content[2].tool_call.subagent_content).toEqual(transcript); + expect(message.content[2].tool_call).not.toHaveProperty('subagentContentOmitted'); + }; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const { createModels, createMethods } = jest.requireActual('@librechat/data-schemas'); + createModels(mongoose); + mockDb.methods = createMethods(mongoose); + await mongoose.connect(mongoServer.getUri()); + + const messagesRouter = require('../messages'); + app = express(); + app.use(express.json({ limit: '10mb' })); + app.use((req, _res, next) => { + req.user = { id: req.headers['x-user'] ?? OWNER }; + next(); + }); + app.use('/api/messages', messagesRouter); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.models.Message.deleteMany({}); + await mongoose.models.Conversation.deleteMany({}); + conversationId = uuidv4(); + await mockDb.methods.saveConvo( + { userId: OWNER }, + { conversationId, endpoint: 'agents', title: 'Long chat' }, + ); + await mockDb.methods.saveMessage( + { userId: OWNER }, + { + messageId: 'user-1', + conversationId, + parentMessageId: '00000000-0000-0000-0000-000000000000', + isCreatedByUser: true, + text: 'build it', + }, + ); + await mockDb.methods.saveMessage( + { userId: OWNER }, + { + messageId: responseId, + conversationId, + parentMessageId: 'user-1', + isCreatedByUser: false, + endpoint: 'agents', + text: '', + content: assistantContent(), + }, + ); + }); + + const loadPreviews = async () => { + const response = await request(app).get(`/api/messages/${conversationId}?toolPreviews=1`); + expect(response.status).toBe(200); + return response.body.find((message) => message.messageId === responseId); + }; + + it('sends previews with markers to a client that asks for them', async () => { + const message = await loadPreviews(); + const bash = message.content[1].tool_call; + expect(bash).toMatchObject({ + id: 'call_bash', + outputTruncated: true, + outputLength: fullOutput.length, + argsTruncated: true, + argsLength: fullArgs.length, + }); + expect(bash.output.length).toBeLessThanOrEqual(512); + expect(bash.output.endsWith('[exit code: 0]')).toBe(true); + expect(JSON.parse(bash.args)).toMatchObject({ command: 'npm run build', intent: 'Build' }); + expect(message.content[2].tool_call).toMatchObject({ + subagentContentOmitted: true, + subagentContentParts: 2, + output: 'child summary', + }); + expect(message.content[2].tool_call).not.toHaveProperty('subagent_content'); + expect(message.content[0]).toEqual({ type: 'text', text: 'Building now', agentId: 'agent_a' }); + }); + + it('sends the full payload to an older client that does not ask', async () => { + const response = await request(app).get(`/api/messages/${conversationId}`); + const message = response.body.find((entry) => entry.messageId === responseId); + expect(message.content[1].tool_call.output).toBe(fullOutput); + expect(message.content[2].tool_call.subagent_content).toEqual(transcript); + expect(JSON.stringify(response.body)).not.toContain('Truncated'); + }); + + it('previews the cursor-paginated conversation read too', async () => { + const response = await request(app).get( + `/api/messages?conversationId=${conversationId}&toolPreviews=1&sortBy=createdAt&sortDirection=asc`, + ); + expect(response.status).toBe(200); + const message = response.body.messages.find((entry) => entry.messageId === responseId); + expect(message.content[1].tool_call.outputTruncated).toBe(true); + }); + + it('serves the full part to its owner and nobody else', async () => { + const url = `/api/messages/${conversationId}/${responseId}/parts/1?toolCallId=call_bash`; + const owner = await request(app).get(url); + expect(owner.status).toBe(200); + expect(owner.body.tool_call.output).toBe(fullOutput); + expect(owner.body.tool_call.args).toBe(fullArgs); + + const other = await request(app).get(url).set('x-user', OTHER); + expect(other.status).toBe(404); + expect(JSON.stringify(other.body)).not.toContain('compiled module'); + }); + + it('does not serve parts of a durable child thread', async () => { + const childId = uuidv4(); + await mongoose.models.Conversation.create({ + conversationId: childId, + user: OWNER, + endpoint: 'agents', + subagentThread: { + rootConversationId: conversationId, + parentConversationId: conversationId, + parentMessageId: responseId, + parentToolCallId: 'call_sub', + subagentType: 'self', + subagentKind: 'agent', + depth: 1, + }, + }); + await mockDb.methods.saveMessage( + { userId: OWNER }, + { messageId: 'child-msg', conversationId: childId, content: assistantContent() }, + ); + const response = await request(app).get(`/api/messages/${childId}/child-msg/parts/1`); + expect(response.status).toBe(404); + }); + + describe('a client holding previews never overwrites stored content', () => { + it('editing a text part of the previewed message keeps every tool call intact', async () => { + const preview = await loadPreviews(); + expect(preview.content[1].tool_call.outputTruncated).toBe(true); + + const response = await request(app) + .put(`/api/messages/${conversationId}/${responseId}`) + .send({ text: 'Building now, edited', index: 0, model: 'gpt' }); + expect(response.status).toBe(200); + + const stored = await storedResponse(); + expect(stored.content[0].text).toBe('Building now, edited'); + await expectStoredContentIntact(); + }); + + it('editing the message text keeps every tool call intact', async () => { + await loadPreviews(); + const response = await request(app) + .put(`/api/messages/${conversationId}/${responseId}`) + .send({ text: 'new text', model: 'gpt' }); + expect(response.status).toBe(200); + await expectStoredContentIntact(); + }); + + it('refuses to save a message body that carries previews', async () => { + const preview = await loadPreviews(); + const response = await request(app) + .post(`/api/messages/${conversationId}`) + .send({ ...preview, conversationId }); + expect(response.status).toBe(400); + await expectStoredContentIntact(); + }); + + it('branching from the previewed message copies the stored content, not the preview', async () => { + await loadPreviews(); + const response = await request(app) + .post('/api/messages/branch') + .send({ messageId: responseId, agentId: 'agent_a' }); + expect(response.status).toBe(201); + expect(response.body.content[1].tool_call.output).toBe(fullOutput); + expect(response.body.content[2].tool_call.subagent_content).toEqual(transcript); + await expectStoredContentIntact(); + }); + + it('forking copies the stored content, not the preview', async () => { + await loadPreviews(); + const { forkConversation } = require('~/server/utils/import/fork'); + const result = await forkConversation({ + originalConvoId: conversationId, + targetMessageId: responseId, + requestUserId: OWNER, + records: true, + }); + const forked = result.messages.find((message) => message.isCreatedByUser === false); + expect(forked.content[1].tool_call.output).toBe(fullOutput); + expect(forked.content[1].tool_call.args).toBe(fullArgs); + expect(forked.content[2].tool_call.subagent_content).toEqual(transcript); + await expectStoredContentIntact(); + }); + }); +}); diff --git a/api/server/routes/__tests__/messages-update.spec.js b/api/server/routes/__tests__/messages-update.spec.js index 5394b49cb41..383527bf497 100644 --- a/api/server/routes/__tests__/messages-update.spec.js +++ b/api/server/routes/__tests__/messages-update.spec.js @@ -7,6 +7,9 @@ jest.mock('@librechat/agents', () => ({ })); jest.mock('@librechat/api', () => ({ + prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), + createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), + rejectToolCallPreviewWrites: (_req, _res, next) => next(), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), stripPrivateMessageFields: jest.requireActual( '../../../../packages/api/src/protection/private/view', @@ -39,6 +42,8 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); + jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () => ({})); jest.mock('~/models', () => ({ diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index f652e8523b6..dc31c412e3e 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -30,8 +30,12 @@ const { createPrivateTextView, stripPrivateMessageFields, applyForcedRetention, + prepareToolCallPreviews, + createToolCallPartHandler, + rejectToolCallPreviewWrites, } = require('@librechat/api'); const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore'); +const { getAppConfig } = require('~/server/services/Config'); const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); const { requireJwtAuth, @@ -60,9 +64,12 @@ const filterFeedbackContent = createContentFilter({ getFilters: (req) => req.config?.filters, extract: (req) => extractFeedbackContent(req.body), }); +const toolCallPreviewDeps = { getAppConfig }; +const readToolCallPart = createToolCallPartHandler({ getMessages: db.getMessages }); const messageMutationMiddleware = [validateMessageReq, configMiddleware]; const storedMessageMutationMiddleware = [ validateMessageReq, + rejectToolCallPreviewWrites, configMiddleware, filterStoredMessageContent, ]; @@ -123,6 +130,7 @@ router.get('/', async (req, res) => { const sortOrder = sortDirection === 'asc' ? 1 : -1; let scopedMessageRead; + const previewToolCalls = prepareToolCallPreviews(req, toolCallPreviewDeps); if (typeof conversationId === 'string') { const ownershipRead = db.getConvoOwnership(user, conversationId); /** Client-facing reads never expose server-private fields such as `contextMeta`. */ @@ -155,13 +163,19 @@ router.get('/', async (req, res) => { throw messageResult.error; } const messages = messageResult.value; - response = { messages: messages?.length ? [messages[0]] : [], nextCursor: null }; + response = { + messages: await previewToolCalls(messages?.length ? [messages[0]] : []), + nextCursor: null, + }; } else if (conversationId) { const messageResult = await scopedMessageRead; if (!messageResult.ok) { throw messageResult.error; } - response = messageResult.value; + response = { + ...messageResult.value, + messages: await previewToolCalls(messageResult.value.messages), + }; } else if (search) { const searchResults = await db.searchMessages( search, @@ -516,6 +530,7 @@ router.get('/:conversationId', prepareMessageRequestValidation, async (req, res) try { const { conversationId } = req.params; const validation = req.messageRequestValidation; + const previewToolCalls = prepareToolCallPreviews(req, toolCallPreviewDeps); // This intentionally starts a user-scoped read before validation resolves; // the response remains gated on validation success below. const messagesPromise = validation.shouldFetchMessages @@ -536,7 +551,7 @@ router.get('/:conversationId', prepareMessageRequestValidation, async (req, res) } const messages = messagesResult?.messages ?? []; - res.status(200).json(messages); + res.status(200).json(await previewToolCalls(messages)); } catch (error) { logger.error('Error fetching messages:', error); res.status(500).json({ error: 'Internal server error' }); @@ -604,6 +619,8 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) = } }); +router.get('/:conversationId/:messageId/parts/:partIndex', validateMessageReq, readToolCallPart); + router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req, res) => { try { const { conversationId, messageId } = req.params; diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index b4c00042730..a089643cc0e 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -5,10 +5,10 @@ import { Button } from '@librechat/client'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import type { TranslationKeys } from '~/hooks'; +import { toolPanelSpacingClassName, useToolContentRequest } from './disclosure'; import CopyButton from '~/components/Messages/Content/CopyButton'; import { unescapeJsonString } from './Parts/parseJsonField'; import MessageIcon from '~/components/Share/MessageIcon'; -import { toolPanelSpacingClassName } from './disclosure'; import { useLocalize, useExpandCollapse } from '~/hooks'; import { useAgentsMapContext } from '~/Providers'; import { cn } from '~/utils'; @@ -107,6 +107,7 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = const localize = useLocalize(); const agentsMap = useAgentsMapContext(); const [showInfo, setShowInfo] = useState(false); + useToolContentRequest(showInfo); const [isCopied, setIsCopied] = useState(false); const copiedTimerRef = useRef>(); const contentId = useId(); @@ -161,7 +162,7 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = + {String(toolCall.output)} + + {String('outputTruncated' in toolCall || 'argsTruncated' in toolCall)} + + + ); +} + +function renderPart(part: TMessageContentParts, options: { sole?: boolean } = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const disclosures: ToolDisclosures = new Map(); + const tree = ( + + + + + + + {isPreviewedToolCallPart(part) ? ( + + {(rendered) => } + + ) : ( + + )} + + + + + + + ); + return { ...render(tree), queryClient }; +} + +describe('previewed tool-call parts', () => { + const getToolCallPart = dataService.getToolCallPart as jest.Mock; + + beforeEach(() => { + getToolCallPart.mockReset(); + }); + + it('loads nothing while the card stays collapsed', () => { + getToolCallPart.mockResolvedValue(stored()); + renderPart(previewPart()); + expect(screen.getByTestId('output')).toHaveTextContent('…'); + expect(getToolCallPart).not.toHaveBeenCalled(); + }); + + it('fetches the stored part when the card opens, showing a status until it arrives', async () => { + let resolve: (value: ToolCallPartResponse) => void = () => undefined; + getToolCallPart.mockReturnValue(new Promise((done) => (resolve = done))); + renderPart(previewPart()); + + fireEvent.click(screen.getByText('toggle')); + expect(await screen.findByRole('status')).toHaveTextContent('com_ui_tool_content_loading'); + expect(getToolCallPart).toHaveBeenCalledWith({ + conversationId: 'convo-1', + messageId: 'msg-1', + partIndex: 2, + toolCallId: 'call_1', + }); + + await act(async () => resolve(stored())); + await waitFor(() => expect(screen.getByTestId('output').textContent).toBe(fullOutput)); + expect(screen.getByTestId('markers')).toHaveTextContent('false'); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('shows an error with a retry that loads the part on the next attempt', async () => { + getToolCallPart + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(stored()); + renderPart(previewPart()); + + fireEvent.click(screen.getByText('toggle')); + const alert = await screen.findByRole('alert', undefined, { timeout: 5_000 }); + expect(alert).toHaveTextContent('com_ui_tool_content_error'); + expect(screen.getByTestId('output')).toHaveTextContent('…'); + + fireEvent.click(screen.getByText('com_ui_retry')); + await waitFor(() => expect(screen.getByTestId('output').textContent).toBe(fullOutput)); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(getToolCallPart).toHaveBeenCalledTimes(3); + }); + + it('fetches once for a card that opens on its own, then serves reopenings from cache', async () => { + getToolCallPart.mockResolvedValue(stored()); + renderPart(previewPart(), { sole: true }); + await waitFor(() => expect(screen.getByTestId('output').textContent).toBe(fullOutput)); + + fireEvent.click(screen.getByText('toggle')); + fireEvent.click(screen.getByText('toggle')); + expect(getToolCallPart).toHaveBeenCalledTimes(1); + }); + + it('leaves a part the server sent in full alone', () => { + const part = { + type: ContentTypes.TOOL_CALL, + tool_call: { id: 'call_1', name: 'bash_tool', args: '{}', output: 'done', progress: 1 }, + } as TMessageContentParts; + expect(isPreviewedToolCallPart(part)).toBe(false); + renderPart(part, { sole: true }); + expect(screen.getByTestId('output')).toHaveTextContent('done'); + expect(getToolCallPart).not.toHaveBeenCalled(); + }); +}); + +describe('withFullToolCall', () => { + it('replaces only content fields and drops the markers', () => { + const part = previewPart({ subagentContentOmitted: true, subagentContentParts: 1 }); + const transcript = [{ type: ContentTypes.TEXT, text: 'child' }] as TMessageContentParts[]; + const merged = withFullToolCall(part as never, { + ...stored().tool_call, + subagent_content: transcript, + }); + expect(merged.tool_call).toEqual({ + id: 'call_1', + type: 'tool_call', + name: 'bash_tool', + args: fullArgs, + output: fullOutput, + progress: 1, + subagent_content: transcript, + }); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/disclosure.ts b/client/src/components/Chat/Messages/Content/disclosure.ts index cc7cba43aa8..6c897d49578 100644 --- a/client/src/components/Chat/Messages/Content/disclosure.ts +++ b/client/src/components/Chat/Messages/Content/disclosure.ts @@ -1,4 +1,4 @@ -import { createContext, useContext, useMemo } from 'react'; +import { createContext, useContext, useEffect, useMemo } from 'react'; import { atom, useAtom } from 'jotai'; import { useRecoilValue } from 'recoil'; import type { PrimitiveAtom } from 'jotai'; @@ -70,6 +70,20 @@ export function useToolAutoExpand() { return autoExpand || soleTool === true; } +/** Asks the enclosing tool-call part to load its stored content in place of a server preview. + * `null` outside a part that holds a preview. */ +export const ToolContentRequestContext = createContext<(() => void) | null>(null); + +/** Requests the part's full content while `active`: its card is open or its panel is showing. */ +export function useToolContentRequest(active: boolean) { + const request = useContext(ToolContentRequestContext); + useEffect(() => { + if (active) { + request?.(); + } + }, [active, request]); +} + /** A tool card's disclosure. The reader's explicit choice lives in the * per-tool atom, so it survives the card remounting when a live batch * regroups. Until they choose, the card follows `useToolAutoExpand` and @@ -78,5 +92,6 @@ export function useToolExpansion(canExpand: boolean) { const autoExpand = useToolAutoExpand(); const [override, setOverride] = useAtom(useToolDisclosure()); const expanded = override ?? (autoExpand && canExpand); + useToolContentRequest(expanded); return [expanded, setOverride] as const; } diff --git a/client/src/components/Chat/Messages/Content/hydration.tsx b/client/src/components/Chat/Messages/Content/hydration.tsx new file mode 100644 index 00000000000..763b8cd388e --- /dev/null +++ b/client/src/components/Chat/Messages/Content/hydration.tsx @@ -0,0 +1,111 @@ +import { useCallback, useMemo, useState } from 'react'; +import { Spinner } from '@librechat/client'; +import { ContentTypes, hasToolCallPreview } from 'librechat-data-provider'; +import type { FullToolCall, TMessageContentParts } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { ToolContentRequestContext } from './disclosure'; +import { useToolCallPartQuery } from '~/data-provider'; +import { useMessageContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; + +type ToolCallPart = Extract; + +/** A tool-call part whose content the server sent as a preview. */ +export function isPreviewedToolCallPart(part: TMessageContentParts): part is ToolCallPart { + return ( + part.type === ContentTypes.TOOL_CALL && + part.tool_call != null && + hasToolCallPreview(part.tool_call as FullToolCall) + ); +} + +/** + * The part with its stored content in place of the preview. Only the content fields come from + * the server copy, and the preview markers go: everything else (progress, status, client-only + * state) stays as the conversation cache has it. + */ +export function withFullToolCall(part: ToolCallPart, full: FullToolCall): ToolCallPart { + const { + outputTruncated: _outputTruncated, + outputLength: _outputLength, + argsTruncated: _argsTruncated, + argsLength: _argsLength, + subagentContentOmitted: _subagentContentOmitted, + subagentContentParts: _subagentContentParts, + ...toolCall + } = part.tool_call as FullToolCall; + return { + ...part, + tool_call: { + ...toolCall, + args: full.args ?? toolCall.args, + output: full.output ?? toolCall.output, + ...(full.subagent_content != null ? { subagent_content: full.subagent_content } : {}), + }, + } as ToolCallPart; +} + +/** + * Renders a previewed tool-call part. Nothing loads until the reader opens the card (or its + * subagent panel); then the stored part is fetched once, cached under its own key, and rendered + * in place of the preview, with a status line while it loads or if it fails. + */ +export function PreviewedToolCallPart({ + part, + children, +}: { + part: ToolCallPart; + children: (part: TMessageContentParts) => ReactNode; +}) { + const localize = useLocalize(); + const { messageId, conversationId, partIndex } = useMessageContext(); + const [requested, setRequested] = useState(false); + const request = useCallback(() => setRequested(true), []); + const toolCallId = (part.tool_call as FullToolCall).id; + const canFetch = !!conversationId && !!messageId && partIndex != null; + const query = useToolCallPartQuery( + { + conversationId: conversationId ?? '', + messageId: messageId ?? '', + partIndex: partIndex ?? 0, + toolCallId, + }, + { enabled: requested && canFetch }, + ); + const full = query.data?.tool_call; + const effectivePart = useMemo( + () => (full == null ? part : withFullToolCall(part, full)), + [full, part], + ); + const retry = useCallback(() => { + query.refetch(); + }, [query]); + + return ( + + {children(effectivePart)} + {requested && full == null && query.isError && ( +
+ {localize('com_ui_tool_content_error')} + +
+ )} + {requested && full == null && !query.isError && canFetch && ( +
+
+ )} +
+ ); +} diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 9a895f17fcf..eed8b2077d2 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -16,6 +16,12 @@ import SubagentThreadPanel from './SubagentThreadPanel'; import { getDraft } from '~/utils'; const mockUseSubagentThreadQuery = jest.fn(); +const mockUseToolCallPartQuery = jest.fn( + (..._args: unknown[]): { data?: unknown; isError: boolean } => ({ + data: undefined, + isError: false, + }), +); const mockUseSubagentActivityStream = jest.fn(); const mockForkMutate = jest.fn(); const mockControlMutate = jest.fn(); @@ -74,6 +80,7 @@ jest.mock('~/data-provider', () => ({ * (`useConfiguredFooter`), so the panel now reads the startup config. */ useGetStartupConfig: () => ({ data: undefined }), useSubagentThreadQuery: (...args: unknown[]) => mockUseSubagentThreadQuery(...args), + useToolCallPartQuery: (...args: unknown[]) => mockUseToolCallPartQuery(...args), subagentThreadHasTaskEvidence: (view: SubagentThreadView | undefined, taskId: string): boolean => view?.messages.some( (message) => @@ -1491,6 +1498,106 @@ describe('SubagentThreadPanel', () => { ).not.toBeInTheDocument(); }); + describe('a foreground call the server sent as a preview', () => { + const previewed: ActiveSubagentPanel = { + host: 'conversation', + parentConversationId: 'parent-conversation', + parentMessageId: 'parent-message', + toolCallId: 'foreground-call', + partIndex: 3, + subagentType: 'researcher', + prompt: 'Review this change.', + legacyOutput: 'Shortened final ans…', + contentPreview: true, + initialProgress: 1, + isSubmitting: false, + }; + + beforeEach(() => { + mockUseSubagentThreadQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + isReadinessPending: false, + }); + }); + + afterEach(() => { + mockUseToolCallPartQuery.mockImplementation(() => ({ data: undefined, isError: false })); + }); + + it('loads the stored part and never shows the shortened output as the activity', () => { + render( + + + , + ); + + expect(mockUseToolCallPartQuery).toHaveBeenCalledWith( + { + conversationId: 'parent-conversation', + messageId: 'parent-message', + partIndex: 3, + toolCallId: 'foreground-call', + }, + { enabled: true }, + ); + expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'loading'); + expect(screen.queryByText('Shortened final ans…')).not.toBeInTheDocument(); + }); + + it('renders the stored transcript once it arrives', () => { + mockUseToolCallPartQuery.mockImplementation(() => ({ + data: { + conversationId: 'parent-conversation', + messageId: 'parent-message', + partIndex: 3, + tool_call: { + id: 'foreground-call', + name: 'subagent', + args: '{"prompt":"Review this change."}', + output: 'Full final answer.', + subagent_content: [{ type: 'text', text: 'Stored review transcript.' }], + }, + }, + isError: false, + })); + + render( + + + , + ); + + expect(screen.getByText('Stored review transcript.')).toBeInTheDocument(); + expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'ready'); + }); + + it('reports a failed load instead of rendering the preview', () => { + mockUseToolCallPartQuery.mockImplementation(() => ({ data: undefined, isError: true })); + + render( + + + , + ); + + expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'error'); + expect(screen.queryByText('Shortened final ans…')).not.toBeInTheDocument(); + }); + + it('reads nothing for a call that arrived in full', () => { + render( + + + , + ); + expect(mockUseToolCallPartQuery).toHaveBeenCalledWith(expect.anything(), { + enabled: false, + }); + }); + }); + it('continues a completed durable agent task as an ordinary conversation snapshot', () => { mockUseSubagentThreadQuery.mockReturnValue({ data: completedView, diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index 6858babb96e..d9bcd4ab5fa 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -22,20 +22,21 @@ import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'; import type { ActiveSubagentPanel, SubagentControlUiState } from './state'; import type { ComposerKeyAction } from '~/utils/shortcuts'; import type { OptionWithIcon } from '~/common'; -import { - adaptDurableThreadActivity, - adaptDurableThreadConversation, - adaptLivePersistedActivity, - mergeChildConversationTurns, - retainBoundedMovingWindowTurns, -} from './adapters'; import { ACTIVE_THREAD_REFRESH_MS, subagentThreadHasTaskEvidence, useForkConvoMutation, + useToolCallPartQuery, useSubagentControlMutation, useSubagentThreadQuery, } from '~/data-provider'; +import { + adaptDurableThreadActivity, + adaptDurableThreadConversation, + adaptLivePersistedActivity, + mergeChildConversationTurns, + retainBoundedMovingWindowTurns, +} from './adapters'; import { activeSubagentPanel, subagentControlStateByTask, @@ -777,22 +778,41 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu }; }, [isMobile]); + /** A previewed call renders nothing until its stored part arrives: the preview's output is a + * shortened copy and its transcript is absent, so neither may stand in for the activity. */ + const awaitsStoredPart = selection.contentPreview === true; + const storedPart = useToolCallPartQuery( + { + conversationId: selection.parentConversationId, + messageId: selection.parentMessageId, + partIndex: selection.partIndex, + toolCallId: selection.toolCallId || undefined, + }, + { enabled: awaitsStoredPart }, + ); + const storedToolCall = awaitsStoredPart ? storedPart.data?.tool_call : undefined; + const persistedContent = storedToolCall?.subagent_content ?? selection.persistedContent; + let legacyOutput = selection.legacyOutput; + if (awaitsStoredPart && legacyOutput != null) { + legacyOutput = storedToolCall?.output; + } + const liveActivity = useMemo( () => adaptLivePersistedActivity({ title: foregroundTitle, prompt: selection.prompt, progress, - persistedContent: selection.persistedContent, + persistedContent, isDetached: selection.durable != null, - legacyOutput: selection.legacyOutput, + legacyOutput, // A detached parent tool step closes as soon as dispatch succeeds; // its terminal status does not describe the still-running child. initialProgress: selection.durable == null ? selection.initialProgress : 0, isSubmitting: selection.durable == null ? selection.isSubmitting : detachedLiveSubmitting, runStepStatus: selection.durable == null ? selection.runStepStatus : undefined, }), - [detachedLiveSubmitting, foregroundTitle, progress, selection], + [detachedLiveSubmitting, foregroundTitle, legacyOutput, persistedContent, progress, selection], ); const activity = useMemo(() => { if (selection.durable == null) return liveActivity; @@ -1230,7 +1250,9 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu ], ); let panelState: 'ready' | 'loading' | 'error' = 'ready'; - if ( + if (awaitsStoredPart && selection.durable == null && storedToolCall == null) { + panelState = storedPart.isError ? 'error' : 'loading'; + } else if ( selection.durable != null && liveActivity.items.length === 0 && (isLoading || isReadinessPending) diff --git a/client/src/components/Chat/Subagents/state.ts b/client/src/components/Chat/Subagents/state.ts index 8373c3d6a3c..4e47b30e3a2 100644 --- a/client/src/components/Chat/Subagents/state.ts +++ b/client/src/components/Chat/Subagents/state.ts @@ -297,6 +297,9 @@ export type ActiveSubagentPanel = { prompt?: string; legacyOutput?: string | null; persistedContent?: TMessageContentParts[]; + /** The tool call came from the server as a preview: its transcript, output or arguments are + * shortened or absent, so the panel loads the stored part before rendering activity. */ + contentPreview?: true; initialProgress: number; isSubmitting: boolean; runStepStatus?: PartMetadata['runStepStatus']; diff --git a/client/src/components/Chat/Trace/Inspector.tsx b/client/src/components/Chat/Trace/Inspector.tsx index 8704ab99b24..1f503c49062 100644 --- a/client/src/components/Chat/Trace/Inspector.tsx +++ b/client/src/components/Chat/Trace/Inspector.tsx @@ -165,12 +165,15 @@ function RawContent({ const COPIED_MS = 2000; const CALL_CONTENT_LENGTH = 4000; -function toContent(value?: string): TTraceContent | undefined { +function toContent(value?: string, previewed = false): TTraceContent | undefined { if (value == null) { return undefined; } const truncated = value.length > CALL_CONTENT_LENGTH; - return { value: truncated ? value.slice(0, CALL_CONTENT_LENGTH) : value, truncated }; + return { + value: truncated ? value.slice(0, CALL_CONTENT_LENGTH) : value, + truncated: truncated || previewed, + }; } /** The saved agent a record ran: who it is, its id to copy, and a chat with it one click away. */ @@ -245,8 +248,14 @@ function ToolCalls({ )} - - + + ))} {fromConversation && ( diff --git a/client/src/components/Chat/Trace/Viewer.tsx b/client/src/components/Chat/Trace/Viewer.tsx index 1c9e207ae83..580ea6086b3 100644 --- a/client/src/components/Chat/Trace/Viewer.tsx +++ b/client/src/components/Chat/Trace/Viewer.tsx @@ -2,7 +2,7 @@ import { useId, useRef, useMemo, useState, useEffect, useCallback, useDeferredVa import axios from 'axios'; import { useAtom } from 'jotai'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { QueryKeys, dataService, resolveTraceViewerConfig } from 'librechat-data-provider'; +import { QueryKeys, resolveTraceViewerConfig } from 'librechat-data-provider'; import { Button, Spinner, EmptyState, FilterInput, buttonVariants } from '@librechat/client'; import { X, @@ -29,6 +29,7 @@ import type { TranslationKeys } from '~/hooks'; import { keepNewestTracePage, useGetStartupConfig, + fetchConversationMessages, useGetLangfuseSessionLinkQuery, useConversationTraceRecordsQuery, } from '~/data-provider'; @@ -112,7 +113,7 @@ export default function Viewer({ /** The chat's own messages, already loaded underneath the trace; they are only read, never fetched here. */ const { data: messages } = useQuery( [QueryKeys.messages, conversationId], - () => dataService.getMessagesByConvoId(conversationId), + () => fetchConversationMessages(conversationId), { enabled: false }, ); diff --git a/client/src/components/Chat/Trace/preview.ts b/client/src/components/Chat/Trace/preview.ts index f2165c98286..c20b183b0d3 100644 --- a/client/src/components/Chat/Trace/preview.ts +++ b/client/src/components/Chat/Trace/preview.ts @@ -9,7 +9,15 @@ const PREVIEW_LENGTH = 160; const ELLIPSIS = '…'; /** `args` is the one-line preview; `input` and `output` are what the chat's own tool card holds. */ -export type ToolCallPreview = { name: string; args: string; input?: string; output?: string }; +export type ToolCallPreview = { + name: string; + args: string; + input?: string; + output?: string; + /** The conversation holds a server preview of the input or output, not the stored value. */ + inputTruncated?: boolean; + outputTruncated?: boolean; +}; /** What one model call of a response produced: the text it wrote and the tools it called. */ export type StepPreview = { text: string; toolCalls: ToolCallPreview[] }; @@ -89,6 +97,10 @@ function callOf(call: ToolCallPart | undefined): ToolCallPreview | null { args: argsPreview(call.args), input: contentOf(call.args), output: 'output' in call ? contentOf(call.output) : undefined, + ...('argsTruncated' in call && call.argsTruncated === true ? { inputTruncated: true } : {}), + ...('outputTruncated' in call && call.outputTruncated === true + ? { outputTruncated: true } + : {}), }; } return null; diff --git a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx index c382452f1f6..6c39ce3792e 100644 --- a/client/src/components/Conversations/ConvoOptions/ShareButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/ShareButton.tsx @@ -17,8 +17,8 @@ import { OGDialogDescription, } from '@librechat/client'; import { useGetLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage'; +import { useGetStartupConfig, fetchConversationMessages } from '~/data-provider'; import SharedLinkCopyButton from './SharedLinkCopyButton'; -import { useGetStartupConfig } from '~/data-provider'; import SharedLinkButton from './SharedLinkButton'; import { buildShareLinkUrl } from '~/utils'; import { useLocalize } from '~/hooks'; @@ -65,7 +65,7 @@ export default function ShareButton({ if (!selectedMessageId) { await queryClient.fetchQuery( [QueryKeys.messages, conversationId], - () => dataService.getMessagesByConvoId(conversationId), + () => fetchConversationMessages(conversationId), { staleTime: 0 }, ); selectedMessageId = getActiveLatestMessage()?.messageId ?? null; diff --git a/client/src/components/Conversations/ConvoOptions/__tests__/ShareButton.test.tsx b/client/src/components/Conversations/ConvoOptions/__tests__/ShareButton.test.tsx index 5a2e476bec7..f6309ca5b20 100644 --- a/client/src/components/Conversations/ConvoOptions/__tests__/ShareButton.test.tsx +++ b/client/src/components/Conversations/ConvoOptions/__tests__/ShareButton.test.tsx @@ -41,6 +41,8 @@ jest.mock('librechat-data-provider/react-query', () => ({ jest.mock('~/data-provider', () => ({ useGetStartupConfig: () => ({ data: { sharedLinksSnapshotFilesEnabled: true } }), + fetchConversationMessages: jest.requireActual('~/data-provider/Messages/queries') + .fetchConversationMessages, })); jest.mock('~/hooks/Messages/useLatestMessage', () => ({ @@ -249,7 +251,9 @@ describe('ShareButton', () => { renderShareButton(); await expect(mockResolveTargetMessageId?.()).resolves.toBe('persisted-message'); - expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID); + expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, { + toolPreviews: true, + }); expect(mockGetMessageById).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, 'persisted-message'); }); @@ -261,7 +265,9 @@ describe('ShareButton', () => { await expect(mockResolveTargetMessageId?.()).rejects.toMatchObject({ code: 'NO_MESSAGES', }); - expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID); + expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(ACTIVE_CONVERSATION_ID, { + toolPreviews: true, + }); expect(mockGetMessageById).not.toHaveBeenCalled(); }); diff --git a/client/src/data-provider/Messages/__tests__/queries.test.ts b/client/src/data-provider/Messages/__tests__/queries.test.ts index 63c3b086c63..62c2ffb95b3 100644 --- a/client/src/data-provider/Messages/__tests__/queries.test.ts +++ b/client/src/data-provider/Messages/__tests__/queries.test.ts @@ -372,7 +372,7 @@ describe('useGetMessagesByConvoId', () => { expect(result.current.data).toEqual(serverMessages); }); expect(mockGetMessagesByConvoId).toHaveBeenCalledTimes(1); - expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId); + expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId, { toolPreviews: true }); unmount(); }); @@ -397,7 +397,7 @@ describe('useGetMessagesByConvoId', () => { }); await waitFor(() => { - expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId); + expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId, { toolPreviews: true }); }); act(() => { @@ -443,7 +443,7 @@ describe('useGetMessagesByConvoId', () => { const refetchPromise = result.current.refetch(); await waitFor(() => { - expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId); + expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId, { toolPreviews: true }); }); act(() => { @@ -520,7 +520,7 @@ describe('useGetMessagesByConvoId', () => { const refetchPromise = result.current.refetch(); await waitFor(() => { - expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId); + expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId, { toolPreviews: true }); }); act(() => { @@ -579,7 +579,9 @@ describe('useGetMessagesByConvoId', () => { await waitFor(() => { expect(result.current.data).toBe(currentMessages); }); - expect(dataService.getMessagesByConvoId).toHaveBeenCalledWith(conversationId); + expect(dataService.getMessagesByConvoId).toHaveBeenCalledWith(conversationId, { + toolPreviews: true, + }); expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toBe(currentMessages); unmount(); @@ -623,7 +625,9 @@ describe('useGetMessagesByConvoId', () => { await waitFor(() => { expect(result.current.data).toBe(currentMessages); }); - expect(dataService.getMessagesByConvoId).toHaveBeenCalledWith(conversationId); + expect(dataService.getMessagesByConvoId).toHaveBeenCalledWith(conversationId, { + toolPreviews: true, + }); expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toBe(currentMessages); expect(logger.warn).toHaveBeenCalledWith( 'messages', diff --git a/client/src/data-provider/Messages/queries.ts b/client/src/data-provider/Messages/queries.ts index d02bc061370..de52163f5f7 100644 --- a/client/src/data-provider/Messages/queries.ts +++ b/client/src/data-provider/Messages/queries.ts @@ -80,6 +80,39 @@ export function shouldPreserveMessagesOnNotFound({ return hasPendingAssistantTail(currentMessages); } +/** + * Loads a conversation's messages for the shared `[messages, id]` cache, with settled tool calls + * as bounded previews. Every writer of that cache uses this, so the cache never flips between + * preview and full payloads; a reader that needs every byte (export) asks for a full load. + */ +export function fetchConversationMessages(conversationId: string): Promise { + return dataService.getMessagesByConvoId(conversationId, { toolPreviews: true }); +} + +/** + * The stored content of one tool-call part, for a preview the reader opened. Kept under its own + * key and never merged into `[messages, id]`, so a refetch of the conversation cannot drop it and + * the conversation cache keeps only previews. + */ +export const useToolCallPartQuery = ( + params: t.ToolCallPartParams, + config?: UseQueryOptions, +): QueryObserverResult => { + const { conversationId, messageId, partIndex, toolCallId } = params; + return useQuery( + [QueryKeys.toolCallPart, conversationId, messageId, partIndex, toolCallId ?? ''], + () => dataService.getToolCallPart(params), + { + staleTime: Infinity, + retry: 1, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + }, + ); +}; + function hasActiveJob(queryClient: QueryClient, id: string) { if (!id) { return false; @@ -119,7 +152,7 @@ export const useGetMessagesByConvoId = ( ); let result: t.TMessage[]; try { - result = await dataService.getMessagesByConvoId(id); + result = await fetchConversationMessages(id); } catch (error) { const currentMessages = queryClient.getQueryData(queryKey); if ( diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 9ed757faa2b..cbdbd5d398b 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -84,6 +84,7 @@ import { generationProtocolHeaders, getGenerationProtocolVersion, supportsGenerationProtocolV2, + fetchConversationMessages, GENERATION_PROTOCOL_VERSION, } from '~/data-provider'; import { @@ -3231,7 +3232,7 @@ export default function useResumableSSE( const fetched = await queryClient.fetchQuery({ queryKey: messageQueryKey, // The first stream can finish before CREATED mounts the saved-chat query. - queryFn: () => dataService.getMessagesByConvoId(convoId), + queryFn: () => fetchConversationMessages(convoId), }); if (!isCurrentSubscription()) { return; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 897fcbb686a..8d8e372ab96 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1596,6 +1596,8 @@ "com_ui_tool_calling": "Calling {{0}}", "com_ui_tool_preparation_time": "Preparation", "com_ui_tool_call_time": "Tool call", + "com_ui_tool_content_error": "Couldn't load the full tool call.", + "com_ui_tool_content_loading": "Loading the full tool call…", "com_ui_tool_total_time": "Total elapsed", "com_ui_during_run_actions": "More send options", "com_ui_edit": "Edit", diff --git a/client/src/utils/approval.spec.ts b/client/src/utils/approval.spec.ts index 099028673de..041bb9b7ed3 100644 --- a/client/src/utils/approval.spec.ts +++ b/client/src/utils/approval.spec.ts @@ -16,6 +16,7 @@ import { isAnsweredAskUserQuestionPart, splitOtherOption, } from './approval'; +import { hasPendingApprovalInPart } from './groupToolCalls'; const toolCallPart = (id: string, extra: Record = {}): TMessageContentParts => ({ @@ -230,6 +231,31 @@ describe('applyPendingAction — subagent-nested tool calls', () => { expect(countTaggedApprovalParts(result, 'a1')).toBe(1); }); + it('still tags the nested pause in a message whose settled calls arrived as previews', () => { + /** The shape a preview-aware conversation load returns: a settled sibling shortened, and + * the running subagent's transcript kept because it holds an unresolved approval. */ + const message = msg({ + content: [ + toolCallPart('done-call', { + output: 'head…tail', + outputTruncated: true, + outputLength: 40_000, + }), + ...(subagentMsg('child-tc1').content as TMessageContentParts[]), + ], + }); + const result = applyPendingAction(message, childAction()); + const parentToolCall = getToolCall(result.content?.[1] as TMessageContentParts) as + | { subagent_content?: TMessageContentParts[] } + | undefined; + expect(getToolCall(parentToolCall?.subagent_content?.[0])?.approval).toMatchObject({ + actionId: 'a1', + }); + expect(hasPendingApprovalInPart(result.content?.[1] as TMessageContentParts)).toBe(true); + expect(hasPendingApprovalInPart(result.content?.[0] as TMessageContentParts)).toBe(false); + expect(countTaggedApprovalParts(result, 'a1')).toBe(1); + }); + it('returns the same message when no nested tool call matches', () => { const message = subagentMsg('child-other'); expect(applyPendingAction(message, childAction())).toBe(message); diff --git a/client/src/utils/tokens.spec.ts b/client/src/utils/tokens.spec.ts index 3b281b5cc44..f58b67fb669 100644 --- a/client/src/utils/tokens.spec.ts +++ b/client/src/utils/tokens.spec.ts @@ -202,6 +202,36 @@ describe('token index', () => { expect(totals.estTokens).toBe(5); }); + it('estimates a previewed tool call from its stored lengths, not the preview', () => { + buildIndex(CONVO, [ + msg('u1', Constants.NO_PARENT, true, 12), + { + messageId: 'a1', + parentMessageId: 'u1', + isCreatedByUser: false, + conversationId: CONVO, + text: '', + content: [ + { + type: 'tool_call', + tool_call: { + name: 'run', + args: 'aa', + argsTruncated: true, + argsLength: 17, + output: 'o…o', + outputTruncated: true, + outputLength: 20, + }, + }, + ], + } as unknown as TMessage, + ]); + + /** name 3 + stored args 17 + stored output 20 = 40 chars / 4 = 10. */ + expect(sumBranch(CONVO, 'a1').estTokens).toBe(10); + }); + it('exposes the count-less tail estimate so live output is not double-counted', () => { buildIndex(CONVO, [ msg('u1', Constants.NO_PARENT, true, 12), diff --git a/client/src/utils/tokens.ts b/client/src/utils/tokens.ts index d342fcfeabb..d39be7ad8d8 100644 --- a/client/src/utils/tokens.ts +++ b/client/src/utils/tokens.ts @@ -187,6 +187,33 @@ function addUsage(target: BranchUsage, usage?: BranchUsage): void { } } +type ToolCallLengthFields = { + args?: unknown; + output?: unknown; + argsLength?: unknown; + outputLength?: unknown; +}; + +/** Stored lengths of a tool call's arguments and output. A preview carries the full lengths in + * `argsLength`/`outputLength`, so estimates match the conversation the model actually sees. */ +function toolCallLengths(call: ToolCallLengthFields): { args: number; output: number } { + let args = 0; + if (typeof call.argsLength === 'number') { + args = call.argsLength; + } else if (typeof call.args === 'string') { + args = call.args.length; + } else if (call.args != null) { + args = JSON.stringify(call.args)?.length ?? 0; + } + let output = 0; + if (typeof call.outputLength === 'number') { + output = call.outputLength; + } else if (typeof call.output === 'string') { + output = call.output.length; + } + return { args, output }; +} + /** Chars of a content part's text, handling both the string and `{ value }` forms. * Reasoning (`think`) and error parts are excluded — the send path strips them * before counting, so they aren't part of the next call's context. */ @@ -199,24 +226,12 @@ function partTextChars(part: unknown): number { return 0; } if (type === 'tool_call') { - const call = (part as { tool_call?: { name?: unknown; args?: unknown; output?: unknown } }) - .tool_call; + const call = (part as { tool_call?: ToolCallLengthFields & { name?: unknown } }).tool_call; if (call == null) { return 0; } - let chars = typeof call.name === 'string' ? call.name.length : 0; - if (typeof call.args === 'string') { - chars += call.args.length; - } else if (call.args != null) { - const serialized = JSON.stringify(call.args); - if (typeof serialized === 'string') { - chars += serialized.length; - } - } - if (typeof call.output === 'string') { - chars += call.output.length; - } - return chars; + const lengths = toolCallLengths(call); + return (typeof call.name === 'string' ? call.name.length : 0) + lengths.args + lengths.output; } const text = (part as { text?: unknown }).text; if (typeof text === 'string') { @@ -253,20 +268,10 @@ function messageToolChars(message: Partial): { chars: number; resultCh if ('name' in call && typeof call.name === 'string') { chars += call.name.length; } - if ('args' in call) { - if (typeof call.args === 'string') { - chars += call.args.length; - } else { - const serialized = JSON.stringify(call.args ?? null); - if (typeof serialized === 'string') { - chars += serialized.length; - } - } - } - if ('output' in call && typeof call.output === 'string') { - chars += call.output.length; - resultChars += call.output.length; - } + const lengths = toolCallLengths(call as ToolCallLengthFields); + chars += 'args' in call && call.args == null ? 4 : lengths.args; + chars += lengths.output; + resultChars += lengths.output; } } return { chars, resultChars }; diff --git a/librechat.example.yaml b/librechat.example.yaml index f1040ff19c7..c5b9d41f8a8 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -17,6 +17,18 @@ projects: # Characters of the project description shown in the workspace and sidebar (maximum 10000). maxDescriptionLength: 1000 +# Tool-call previews: a long conversation's tool outputs, arguments and subagent transcripts +# can run to megabytes, though their cards render collapsed. The web client asks for bounded +# previews when it loads a conversation (the start and end of each long output, arguments +# with their long strings shortened, no subagent transcripts) and loads a call in full when +# the reader opens it. Clients that do not ask, exports and share links get full content. +# Every field is optional and defaults to the value shown; enabled: false always sends the +# full payload. +# toolCallPreviews: +# enabled: true +# outputChars: 512 # characters of each output kept, half from the start and half from the end +# argsChars: 512 # characters of serialized arguments kept; JSON stays valid JSON + # Langfuse connections are managed through Settings > Langfuse when available. # That flow verifies the credentials and stores the secret key encrypted; do not # place a plaintext langfuse.secretKey in this file. Environment-managed central diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index be7e6d7d0b9..b5d05ebf3fa 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -85,6 +85,8 @@ export * from './prompts'; export * from './projects'; /* Conversations */ export * from './conversations'; +/* Messages */ +export * from './messages'; /* Skills */ export * from './schedules'; export * from './schedules/service'; diff --git a/packages/api/src/messages/index.ts b/packages/api/src/messages/index.ts new file mode 100644 index 00000000000..4d62fa23e77 --- /dev/null +++ b/packages/api/src/messages/index.ts @@ -0,0 +1,2 @@ +export * from './parts'; +export * from './previews'; diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts new file mode 100644 index 00000000000..850d3d1c470 --- /dev/null +++ b/packages/api/src/messages/parts.spec.ts @@ -0,0 +1,243 @@ +import express from 'express'; +import mongoose from 'mongoose'; +import request from 'supertest'; +import { ContentTypes } from 'librechat-data-provider'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { + logger, + createMethods, + createModels, + tenantStorage, + CLIENT_MESSAGE_SELECT, +} from '@librechat/data-schemas'; +import type { AllMethods, IMessage } from '@librechat/data-schemas'; +import type { NextFunction, Request, Response } from 'express'; +import { previewMessagesToolCalls } from './previews'; +import { createToolCallPartHandler } from './parts'; + +let mongod: MongoMemoryServer; +let methods: AllMethods; +let app: express.Express; + +const OWNER = 'owner-user'; +const conversationId = '7f9c2b1e-4a5d-4c3b-9e8f-1a2b3c4d5e6f'; +const otherConversationId = '0b1c2d3e-4f50-4617-8293-a4b5c6d7e8f9'; +const messageId = 'msg-parts'; + +const longOutput = `stdout:\n${'line of build output\n'.repeat(2_000)}[exit code: 0]`; +const longArgs = JSON.stringify({ + command: 'npm run build', + intent: 'Build', + pad: 'p'.repeat(5_000), +}); +const transcript = [ + { type: ContentTypes.TEXT, text: 'child text' }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'child_1', + name: 'read_file', + args: '{"path":"a"}', + output: 'x'.repeat(3_000), + }, + }, +]; + +function content() { + return [ + { type: ContentTypes.TEXT, text: 'Working on it' }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call_bash', + type: 'tool_call', + name: 'bash_tool', + args: longArgs, + output: longOutput, + progress: 1, + backgroundTask: { + version: 1, + taskId: 'task-1', + toolName: 'bash_tool', + status: 'completed', + settledAt: new Date('2026-10-01T00:00:00Z'), + resultClaim: { kind: 'manual', claimId: 'secret-claim', claimedAt: new Date() }, + }, + }, + }, + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: 'call_sub', + type: 'tool_call', + name: 'subagent', + args: '{"prompt":"look around"}', + output: 'child summary', + progress: 1, + subagent_content: transcript, + }, + }, + ]; +} + +async function seed(user = OWNER, overrides: Partial = {}) { + await methods.saveMessage( + { userId: user }, + { + messageId, + conversationId, + parentMessageId: 'parent', + isCreatedByUser: false, + text: '', + content: content(), + ...overrides, + }, + ); +} + +function withUser(req: Request, _res: Response, next: NextFunction) { + (req as Request & { user: { id: string } }).user = { + id: (req.headers['x-user'] as string | undefined) ?? OWNER, + }; + const tenantId = req.headers['x-tenant'] as string | undefined; + if (tenantId == null) { + next(); + return; + } + tenantStorage.run({ tenantId }, () => next()); +} + +const partUrl = (index: number | string, toolCallId?: string, convo = conversationId) => + `/api/messages/${convo}/${messageId}/parts/${index}${ + toolCallId == null ? '' : `?toolCallId=${encodeURIComponent(toolCallId)}` + }`; + +beforeAll(async () => { + jest.spyOn(logger, 'error').mockImplementation(() => logger); + mongod = await MongoMemoryServer.create(); + createModels(mongoose); + methods = createMethods(mongoose); + await mongoose.connect(mongod.getUri()); + app = express(); + app.use(withUser); + app.get( + '/api/messages/:conversationId/:messageId/parts/:partIndex', + createToolCallPartHandler({ getMessages: methods.getMessages }), + ); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongod.stop(); +}); + +beforeEach(async () => { + await (mongoose.models.Message as mongoose.Model).deleteMany({}); +}); + +describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => { + it('returns the stored tool call in full, exactly as a full conversation load sends it', async () => { + await seed(); + const response = await request(app).get(partUrl(1, 'call_bash')); + expect(response.status).toBe(200); + + const [stored] = await methods.getMessages( + { conversationId, messageId, user: OWNER }, + CLIENT_MESSAGE_SELECT, + ); + const fullLoadPart = JSON.parse(JSON.stringify((stored.content as unknown[])[1])); + expect(response.body).toEqual({ + conversationId, + messageId, + partIndex: 1, + tool_call: fullLoadPart.tool_call, + }); + expect(response.body.tool_call.output).toBe(longOutput); + expect(response.body.tool_call.args).toBe(longArgs); + expect(response.body.tool_call.backgroundTask).toMatchObject({ taskId: 'task-1' }); + expect(response.body.tool_call.backgroundTask).not.toHaveProperty('resultClaim'); + }); + + it('restores everything a preview left out, subagent transcript included', async () => { + await seed(); + const [stored] = await methods.getMessages( + { conversationId, messageId, user: OWNER }, + CLIENT_MESSAGE_SELECT, + ); + const [previewed] = previewMessagesToolCalls([stored], { outputChars: 512, argsChars: 512 }); + const previewParts = previewed.content as Array<{ tool_call?: Record }>; + expect(previewParts[1].tool_call).toMatchObject({ outputTruncated: true, argsTruncated: true }); + expect(previewParts[2].tool_call).toMatchObject({ + subagentContentOmitted: true, + subagentContentParts: 2, + }); + + const sub = await request(app).get(partUrl(2, 'call_sub')); + expect(sub.status).toBe(200); + expect(sub.body.tool_call.subagent_content).toEqual(transcript); + expect(sub.body.tool_call).not.toHaveProperty('subagentContentOmitted'); + }); + + it('finds the part by tool-call id when the client copy is out of position', async () => { + await seed(); + const response = await request(app).get(partUrl(0, 'call_sub')); + expect(response.status).toBe(200); + expect(response.body.partIndex).toBe(2); + expect(response.body.tool_call.id).toBe('call_sub'); + }); + + it('answers not found for a missing part, a non-tool part, or an unknown id', async () => { + await seed(); + expect((await request(app).get(partUrl(9))).status).toBe(404); + expect((await request(app).get(partUrl(0))).status).toBe(404); + expect((await request(app).get(partUrl(1, 'call_missing'))).status).toBe(404); + }); + + it("never returns another user's message", async () => { + await seed(); + const response = await request(app).get(partUrl(1)).set('x-user', 'intruder'); + expect(response.status).toBe(404); + expect(JSON.stringify(response.body)).not.toContain('line of build output'); + }); + + it('never returns a message through a different conversation id', async () => { + await seed(); + const response = await request(app).get(partUrl(1, undefined, otherConversationId)); + expect(response.status).toBe(404); + }); + + it('never crosses tenants, even for the same user id', async () => { + await tenantStorage.run({ tenantId: 'tenant-a' }, () => seed()); + const sameTenant = await request(app).get(partUrl(1)).set('x-tenant', 'tenant-a'); + expect(sameTenant.status).toBe(200); + const otherTenant = await request(app).get(partUrl(1)).set('x-tenant', 'tenant-b'); + expect(otherTenant.status).toBe(404); + }); + + it('rejects malformed coordinates before reading', async () => { + const getMessages = jest.fn(); + const strict = express(); + strict.use(withUser); + strict.get( + '/api/messages/:conversationId/:messageId/parts/:partIndex', + createToolCallPartHandler({ getMessages }), + ); + expect((await request(strict).get(partUrl('-1'))).status).toBe(400); + expect((await request(strict).get(partUrl('1.5'))).status).toBe(400); + expect((await request(strict).get(partUrl('9999999'))).status).toBe(400); + expect((await request(strict).get(partUrl(1, 'x'.repeat(600)))).status).toBe(400); + expect(getMessages).not.toHaveBeenCalled(); + }); + + it('reports a read failure as a server error without leaking it', async () => { + const failing = express(); + failing.use(withUser); + failing.get( + '/api/messages/:conversationId/:messageId/parts/:partIndex', + createToolCallPartHandler({ getMessages: jest.fn().mockRejectedValue(new Error('db down')) }), + ); + const response = await request(failing).get(partUrl(1)); + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Internal server error' }); + }); +}); diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts new file mode 100644 index 00000000000..fd83db10886 --- /dev/null +++ b/packages/api/src/messages/parts.ts @@ -0,0 +1,174 @@ +import { ContentTypes } from 'librechat-data-provider'; +import { logger, CLIENT_MESSAGE_SELECT } from '@librechat/data-schemas'; +import type { FullToolCall, ToolCallPartResponse } from 'librechat-data-provider'; +import type { Request, Response } from 'express'; + +/** Scopes a read to one message the authenticated user owns. */ +export interface ToolCallPartFilter { + user: string; + conversationId: string; + messageId: string; +} + +interface StoredMessage { + content?: unknown[] | null; +} + +export interface ToolCallPartDeps { + /** + * The data-schemas `getMessages` method. The projection is supplied here, so the part leaves + * with exactly the fields a conversation load sends for it, private fields excluded. + */ + getMessages: (filter: ToolCallPartFilter, select: string) => Promise; +} + +export interface ToolCallPartInput extends ToolCallPartFilter { + partIndex: number; + toolCallId?: string; +} + +export type ToolCallPartErrorCode = 'message_not_found' | 'part_not_found'; + +export type ToolCallPartResult = + | { ok: true; value: { partIndex: number; toolCall: FullToolCall } } + | { ok: false; error: { code: ToolCallPartErrorCode } }; + +interface StoredToolCallPart { + type?: string; + tool_call?: FullToolCall; +} + +const MAX_PART_INDEX = 100_000; +const MAX_TOOL_CALL_ID_LENGTH = 512; +const PART_INDEX_PATTERN = /^\d{1,6}$/; + +function getAgentToolCall(part: unknown): FullToolCall | undefined { + if (part == null || typeof part !== 'object') { + return undefined; + } + const { type, tool_call: toolCall } = part as StoredToolCallPart; + if (type !== ContentTypes.TOOL_CALL || toolCall == null || typeof toolCall !== 'object') { + return undefined; + } + return toolCall.type == null || toolCall.type === 'tool_call' ? toolCall : undefined; +} + +/** + * Finds the part at its index, or by tool-call id when the client's copy of the content no + * longer lines up with storage (a client-only card was inserted or removed). An id that matches + * a different part than the index points at never wins over the indexed part. + */ +function locatePart( + content: unknown[], + partIndex: number, + toolCallId?: string, +): { partIndex: number; toolCall: FullToolCall } | undefined { + const indexed = getAgentToolCall(content[partIndex]); + if (indexed != null && (toolCallId == null || indexed.id === toolCallId)) { + return { partIndex, toolCall: indexed }; + } + if (toolCallId == null) { + return undefined; + } + for (let i = 0; i < content.length; i++) { + const toolCall = getAgentToolCall(content[i]); + if (toolCall?.id === toolCallId) { + return { partIndex: i, toolCall }; + } + } + return undefined; +} + +/** Reads one stored tool-call part in full, scoped to the requesting user's message. */ +export async function readToolCallPart( + deps: ToolCallPartDeps, + input: ToolCallPartInput, +): Promise { + const { user, conversationId, messageId, partIndex, toolCallId } = input; + const messages = await deps.getMessages( + { user, conversationId, messageId }, + CLIENT_MESSAGE_SELECT, + ); + const content = messages?.[0]?.content; + if (!Array.isArray(content)) { + return { ok: false, error: { code: 'message_not_found' } }; + } + const located = locatePart(content, partIndex, toolCallId); + if (located == null) { + return { ok: false, error: { code: 'part_not_found' } }; + } + return { ok: true, value: located }; +} + +type ToolCallPartRequest = Request< + { conversationId?: string; messageId?: string; partIndex?: string }, + ToolCallPartResponse | { error: string }, + unknown, + { toolCallId?: unknown } +> & { user?: { id?: string } }; + +function parsePartIndex(value: string | undefined): number | undefined { + if (value == null || !PART_INDEX_PATTERN.test(value)) { + return undefined; + } + const index = Number(value); + return index <= MAX_PART_INDEX ? index : undefined; +} + +/** `undefined` when absent, `null` when present but unusable. */ +function parseToolCallId(value: unknown): string | undefined | null { + if (value == null) { + return undefined; + } + if (typeof value !== 'string' || value === '' || value.length > MAX_TOOL_CALL_ID_LENGTH) { + return null; + } + return value; +} + +/** + * `GET /api/messages/:conversationId/:messageId/parts/:partIndex[?toolCallId=]`. Mount it behind + * the same authentication and conversation-ownership validation as the message routes; the read + * itself is also scoped to the authenticated user, so a message id from another conversation or + * account resolves to not found. + */ +export function createToolCallPartHandler( + deps: ToolCallPartDeps, +): (req: ToolCallPartRequest, res: Response) => Promise { + return async (req, res) => { + const userId = req.user?.id; + const { conversationId, messageId } = req.params; + const partIndex = parsePartIndex(req.params.partIndex); + const toolCallId = parseToolCallId(req.query.toolCallId); + if (!userId || !conversationId || !messageId) { + res.status(404).json({ error: 'Tool call not found' }); + return; + } + if (partIndex == null || toolCallId === null) { + res.status(400).json({ error: 'Invalid tool call part' }); + return; + } + try { + const result = await readToolCallPart(deps, { + user: userId, + conversationId, + messageId, + partIndex, + toolCallId, + }); + if (!result.ok) { + res.status(404).json({ error: 'Tool call not found' }); + return; + } + res.status(200).json({ + conversationId, + messageId, + partIndex: result.value.partIndex, + tool_call: result.value.toolCall, + }); + } catch (error) { + logger.error('[toolCallPart] Failed to read tool call part', error); + res.status(500).json({ error: 'Internal server error' }); + } + }; +} diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts new file mode 100644 index 00000000000..f8a6f62a2b9 --- /dev/null +++ b/packages/api/src/messages/previews.spec.ts @@ -0,0 +1,349 @@ +import { ContentTypes, toolCallPreviewsConfigSchema } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { + previewToolCall, + previewOutputText, + previewToolCallArgs, + wantsToolCallPreviews, + containsToolCallPreviews, + prepareToolCallPreviews, + previewMessagesToolCalls, + rejectToolCallPreviewWrites, + TOOL_CALL_PREVIEW_ELISION, +} from './previews'; + +const limits = { outputChars: 1_024, argsChars: 1_024 }; + +type TestToolCall = { + type?: string; + id?: string; + name?: string; + args?: unknown; + output?: string; + executor?: string; + progress?: number; + approval?: unknown; + subagent_content?: unknown[]; + outputTruncated?: true; + argsTruncated?: true; + subagentContentOmitted?: true; + function?: unknown; +}; + +type TestMessage = { messageId?: string; content?: unknown[] }; + +const toolPart = (toolCall: TestToolCall) => ({ + type: ContentTypes.TOOL_CALL, + tool_call: { type: 'tool_call', id: 'call_1', name: 'bash_tool', ...toolCall } as TestToolCall, +}); + +const longOutput = (head: string, tail: string, fill = 'x'.repeat(20_000)) => + `${head}${fill}${tail}`; + +describe('previewOutputText', () => { + it('keeps short output untouched', () => { + expect(previewOutputText('done', 1_024)).toBe('done'); + }); + + it('keeps the start and the end within the bound', () => { + const output = longOutput('stdout:\nhello', '\n[exit code: 2]'); + const preview = previewOutputText(output, 1_024); + expect(preview.length).toBeLessThanOrEqual(1_024); + expect(preview.startsWith('stdout:\nhello')).toBe(true); + expect(preview.endsWith('\n[exit code: 2]')).toBe(true); + expect(preview).toContain(TOOL_CALL_PREVIEW_ELISION); + }); + + it('never splits a surrogate pair at either cut', () => { + const output = '😀'.repeat(5_000); + const preview = previewOutputText(output, 1_024); + const [head, tail] = preview.split(TOOL_CALL_PREVIEW_ELISION); + expect(head).toBe('😀'.repeat(head.length / 2)); + expect(tail).toBe('😀'.repeat(tail.length / 2)); + }); +}); + +describe('previewToolCallArgs', () => { + it('returns nothing for arguments within the bound', () => { + expect(previewToolCallArgs('{"command":"ls"}', 1_024)).toBeUndefined(); + expect(previewToolCallArgs({ command: 'ls' }, 1_024)).toBeUndefined(); + }); + + it('keeps JSON string arguments parseable, with every field still present', () => { + const args = JSON.stringify({ + command: 'cat big.log', + intent: 'Reading the build log', + content: 'y'.repeat(50_000), + }); + const preview = previewToolCallArgs(args, 1_024); + expect(preview?.length).toBe(args.length); + expect(typeof preview?.args).toBe('string'); + const parsed = JSON.parse(preview?.args as string); + expect(parsed.command).toBe('cat big.log'); + expect(parsed.intent).toBe('Reading the build log'); + expect(parsed.content.startsWith('yyy')).toBe(true); + expect(parsed.content.endsWith('…')).toBe(true); + expect((preview?.args as string).length).toBeLessThanOrEqual(1_024); + }); + + it('shortens only the longest strings, keeping every short field exact', () => { + const args = JSON.stringify({ + intent: 'Patch the route so the part endpoint reuses the ownership probe', + path: 'api/server/routes/messages.js', + replace: 'r'.repeat(4_000), + search: 's'.repeat(3_000), + flags: { dryRun: false, count: 3 }, + tags: ['a', 'b'], + }); + const parsed = JSON.parse(previewToolCallArgs(args, 512)?.args as string); + expect(parsed.intent).toBe('Patch the route so the part endpoint reuses the ownership probe'); + expect(parsed.path).toBe('api/server/routes/messages.js'); + expect(parsed.flags).toEqual({ dryRun: false, count: 3 }); + expect(parsed.tags).toEqual(['a', 'b']); + expect(parsed.replace.length).toBe(parsed.search.length); + }); + + it('keeps object arguments as objects', () => { + const args = { path: 'src/index.ts', content: 'z'.repeat(10_000) }; + const preview = previewToolCallArgs(args, 1_024); + expect(preview?.args).toEqual({ path: 'src/index.ts', content: expect.any(String) }); + expect(preview?.length).toBe(JSON.stringify(args).length); + }); + + it('falls back to a bounded prefix for text that is not JSON', () => { + const args = 'q'.repeat(5_000); + const preview = previewToolCallArgs(args, 1_024); + expect(preview).toEqual({ args: 'q'.repeat(1_024), length: 5_000 }); + }); + + it('falls back to a bounded prefix when even short strings cannot fit', () => { + const args = JSON.stringify( + Object.fromEntries(Array.from({ length: 400 }, (_, i) => [`k${i}`, i])), + ); + const preview = previewToolCallArgs(args, 1_024); + expect((preview?.args as string).length).toBe(1_024); + }); +}); + +describe('previewToolCall', () => { + it('shortens a settled call and records what it shortened', () => { + const output = longOutput('Error: failed', '\n Please fix your mistakes.'); + const args = JSON.stringify({ command: 'run', content: 'a'.repeat(5_000) }); + const toolCall = toolPart({ output, args, progress: 1 }).tool_call; + const preview = previewToolCall(toolCall, limits); + + expect(preview).not.toBe(toolCall); + expect(preview).toMatchObject({ + id: 'call_1', + name: 'bash_tool', + progress: 1, + outputTruncated: true, + outputLength: output.length, + argsTruncated: true, + argsLength: args.length, + }); + expect(preview.output).toMatch(/^Error: failed/); + expect(preview.output).toMatch(/\n Please fix your mistakes\.$/); + expect(toolCall.output).toBe(output); + }); + + it('keeps JSON output parseable, so result cards still read their fields', () => { + const output = JSON.stringify({ + status: 'completed', + task_id: 'task_1', + output: 'line\n'.repeat(5_000), + }); + const preview = previewToolCall( + toolPart({ name: 'check_background_task', output }).tool_call, + limits, + ); + expect(preview.outputTruncated).toBe(true); + const parsed = JSON.parse(preview.output as string); + expect(parsed).toMatchObject({ status: 'completed', task_id: 'task_1' }); + expect((preview.output as string).length).toBeLessThanOrEqual(limits.outputChars); + }); + + it('keeps an attached-workspace exit trailer whole even past half the bound', () => { + const trailer = + '\n[exit code: 1][timed out]\nCommand reached timeoutMs: 120000. ' + + 'r'.repeat(300) + + '\n[directory hint: ' + + 'h'.repeat(300) + + ']'; + const output = `[starting directory: "workspace/"]\nstdout:\n${'o'.repeat(20_000)}\n${trailer}`; + const attached = previewToolCall( + toolPart({ output, executor: 'attached_workspace' }).tool_call, + limits, + ); + expect(attached.output?.endsWith(trailer)).toBe(true); + expect(attached.output?.startsWith('[starting directory: "workspace/"]\nstdout:\n')).toBe(true); + + const sandbox = previewToolCall(toolPart({ output }).tool_call, limits); + expect(sandbox.output?.length).toBeLessThanOrEqual(limits.outputChars); + expect(sandbox.output?.endsWith(trailer)).toBe(false); + }); + + it('returns the same object when nothing exceeds the bounds', () => { + const toolCall = toolPart({ output: 'ok', args: '{}' }).tool_call; + expect(previewToolCall(toolCall, limits)).toBe(toolCall); + }); + + it('leaves calls without output untouched, pending approvals included', () => { + const args = JSON.stringify({ command: 'rm -rf build', content: 'a'.repeat(5_000) }); + const pending = toolPart({ + args, + approval: { actionId: 'a1', allowed_decisions: ['edit'] }, + }).tool_call; + expect(previewToolCall(pending, limits)).toBe(pending); + const running = toolPart({ args, output: '' }).tool_call; + expect(previewToolCall(running, limits)).toBe(running); + }); + + it('omits a settled subagent transcript and counts its parts', () => { + const transcript = [ + { type: ContentTypes.TEXT, text: 'child says hi' }, + toolPart({ id: 'child_call', output: 'done' }), + ]; + const toolCall = toolPart({ + name: 'subagent', + args: '{"prompt":"look"}', + output: 'summary', + subagent_content: transcript, + }).tool_call; + const preview = previewToolCall(toolCall, limits); + expect(preview).not.toHaveProperty('subagent_content'); + expect(preview).toMatchObject({ subagentContentOmitted: true, subagentContentParts: 2 }); + expect(preview.output).toBe('summary'); + }); + + it('keeps a subagent transcript that still holds an unresolved approval', () => { + const transcript = [ + toolPart({ + id: 'child_call', + args: '{}', + approval: { actionId: 'a1', allowed_decisions: ['approve'] }, + }), + ]; + const toolCall = toolPart({ + name: 'subagent', + output: 'partial', + subagent_content: transcript, + }).tool_call; + expect(previewToolCall(toolCall, limits)).toBe(toolCall); + }); + + it('keeps a subagent transcript while the subagent is still running', () => { + const transcript = [{ type: ContentTypes.TEXT, text: 'working' }]; + const toolCall = toolPart({ name: 'subagent', subagent_content: transcript }).tool_call; + expect(previewToolCall(toolCall, limits)).toBe(toolCall); + }); + + it('leaves the question-and-answer record and legacy Assistants calls whole', () => { + const ask = toolPart({ name: 'ask_user_question', output: 'a'.repeat(5_000) }).tool_call; + expect(previewToolCall(ask, limits)).toBe(ask); + const legacy = { type: 'function', function: { name: 'x', output: 'a'.repeat(5_000) } }; + expect(previewToolCall(legacy, limits)).toBe(legacy); + }); +}); + +describe('previewMessagesToolCalls', () => { + it('copies only the messages and parts that change, keeping content positions', () => { + const untouched = { messageId: 'm1', content: [{ type: ContentTypes.TEXT, text: 'hello' }] }; + const textPart = { type: ContentTypes.TEXT, text: 'before' }; + const bigPart = toolPart({ output: 'o'.repeat(10_000) }); + const smallPart = toolPart({ id: 'call_2', output: 'small' }); + const changed = { messageId: 'm2', content: [textPart, bigPart, smallPart] }; + const messages: TestMessage[] = [untouched, changed, { messageId: 'm3' }]; + + const result = previewMessagesToolCalls(messages, limits); + expect(result).not.toBe(messages); + expect(result[0]).toBe(untouched); + expect(result[2]).toBe(messages[2]); + expect(result[1].content).toHaveLength(3); + expect(result[1].content?.[0]).toBe(textPart); + expect(result[1].content?.[2]).toBe(smallPart); + expect(result[1].content?.[1]).toMatchObject({ tool_call: { outputTruncated: true } }); + expect(bigPart.tool_call.output).toHaveLength(10_000); + }); + + it('returns the same array when nothing needs shortening', () => { + const messages: TestMessage[] = [{ content: [toolPart({ output: 'ok' })] }]; + expect(previewMessagesToolCalls(messages, limits)).toBe(messages); + }); +}); + +describe('prepareToolCallPreviews', () => { + const messages: TestMessage[] = [{ content: [toolPart({ output: 'o'.repeat(10_000) })] }]; + const config = (toolCallPreviews: Partial) => + ({ + toolCallPreviews: toolCallPreviewsConfigSchema.parse(toolCallPreviews), + }) as AppConfig; + + it('sends the full payload to a client that did not ask, without reading config', async () => { + const getAppConfig = jest.fn(); + const preview = prepareToolCallPreviews({ query: {} }, { getAppConfig }); + await expect(preview(messages)).resolves.toBe(messages); + expect(getAppConfig).not.toHaveBeenCalled(); + }); + + it('ignores an unknown preview version', () => { + expect(wantsToolCallPreviews({ toolPreviews: '2' })).toBe(false); + expect(wantsToolCallPreviews({ toolPreviews: ['1'] })).toBe(false); + expect(wantsToolCallPreviews({ toolPreviews: '1' })).toBe(true); + }); + + it('starts the config read immediately and applies its bounds', async () => { + const getAppConfig = jest.fn().mockResolvedValue(config({ outputChars: 512 })); + const preview = prepareToolCallPreviews( + { query: { toolPreviews: '1' }, user: { id: 'u1', role: 'USER', tenantId: 't1' } }, + { getAppConfig }, + ); + expect(getAppConfig).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'u1', role: 'USER', tenantId: 't1' }), + ); + const [message] = await preview(messages); + const part = message.content?.[0] as ReturnType; + expect(part.tool_call.output?.length).toBeLessThanOrEqual(512); + }); + + it('sends in full when the deployment turned previews off', async () => { + const getAppConfig = jest.fn().mockResolvedValue(config({ enabled: false })); + const preview = prepareToolCallPreviews({ query: { toolPreviews: '1' } }, { getAppConfig }); + await expect(preview(messages)).resolves.toBe(messages); + }); + + it('uses the default bounds when config cannot be read', async () => { + const getAppConfig = jest.fn().mockRejectedValue(new Error('config down')); + const preview = prepareToolCallPreviews({ query: { toolPreviews: '1' } }, { getAppConfig }); + const [message] = await preview(messages); + expect(message.content?.[0]).toMatchObject({ tool_call: { outputTruncated: true } }); + }); +}); + +describe('rejectToolCallPreviewWrites', () => { + const run = (content: unknown) => { + const json = jest.fn(); + const status = jest.fn(() => ({ json })); + const next = jest.fn(); + rejectToolCallPreviewWrites({ body: { content } }, { status }, next); + return { status, json, next }; + }; + + it('refuses content carrying a preview, nested transcripts included', () => { + const top = run([toolPart({ output: 'x', outputTruncated: true })]); + expect(top.status).toHaveBeenCalledWith(400); + expect(top.next).not.toHaveBeenCalled(); + + const nested = run([ + toolPart({ name: 'subagent', subagent_content: [toolPart({ argsTruncated: true })] }), + ]); + expect(nested.status).toHaveBeenCalledWith(400); + expect(containsToolCallPreviews([toolPart({ subagentContentOmitted: true })])).toBe(true); + }); + + it('passes full content through', () => { + const result = run([toolPart({ output: 'x'.repeat(10_000) })]); + expect(result.next).toHaveBeenCalled(); + expect(result.status).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts new file mode 100644 index 00000000000..76e38d42ce2 --- /dev/null +++ b/packages/api/src/messages/previews.ts @@ -0,0 +1,457 @@ +import { logger } from '@librechat/data-schemas'; +import { + ContentTypes, + TOOL_CALL_PREVIEWS_PARAM, + TOOL_CALL_PREVIEWS_VERSION, + COMMAND_RESULT_TRAILER, + hasToolCallPreview, + toolCallPreviewsConfigSchema, +} from 'librechat-data-provider'; +import type { TToolCallPreviewsConfig, ToolCallPreviewMarkers } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { AppConfigUserLike, GetAppConfigOptions } from '~/app/service'; +import { getAppConfigOptionsFromUser } from '~/app/service'; + +export type ToolCallPreviewLimits = Pick; + +/** Separates the kept start and end of an output preview. */ +export const TOOL_CALL_PREVIEW_ELISION = '\n…\n'; + +/** Share of a text output preview taken from the start; the rest keeps the end. */ +const OUTPUT_HEAD_SHARE = 0.5; + +/** Shortest a string inside a JSON preview gets before the structure is abandoned for text. */ +const MIN_JSON_STRING_CHARS = 8; + +/** Ceiling on a kept exit-status trailer, so a malformed one cannot defeat the bound. */ +const MAX_TRAILER_CHARS = 2_048; + +/** + * Tools whose cards render their full record without an expand step, so a preview would + * be what the reader sees: the question-and-answer record reads both fields directly. + */ +const FULL_CONTENT_TOOLS: ReadonlySet = new Set(['ask_user_question']); + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +/** The stored fields this module reads; everything else on the tool call passes through. */ +interface StoredToolCall extends ToolCallPreviewMarkers { + type?: string; + name?: string; + executor?: string; + output?: unknown; + args?: unknown; + approval?: unknown; + subagent_content?: unknown; +} + +interface StoredToolCallPart { + type?: string; + tool_call?: StoredToolCall; +} + +/** A message as read for a client; only `content` is inspected. */ +export interface PreviewableMessage { + content?: unknown[] | null; +} + +const isObject = (value: unknown): value is object => value != null && typeof value === 'object'; + +const isToolCallPart = ( + part: unknown, +): part is StoredToolCallPart & { tool_call: StoredToolCall } => + isObject(part) && + (part as StoredToolCallPart).type === ContentTypes.TOOL_CALL && + isObject((part as StoredToolCallPart).tool_call); + +/** Agent tool calls only; legacy Assistants shapes keep their own nested output fields. */ +const isAgentToolCall = (toolCall: StoredToolCall): boolean => + toolCall.type == null || toolCall.type === 'tool_call'; + +const hasOutput = (toolCall: StoredToolCall): boolean => + typeof toolCall.output === 'string' && toolCall.output.length > 0; + +/** An unresolved approval anywhere in the parts, including inside nested subagent runs. */ +function hasPendingApproval(parts: unknown[]): boolean { + const stack: unknown[][] = [parts]; + while (stack.length > 0) { + const current = stack.pop() ?? []; + for (const part of current) { + if (!isToolCallPart(part)) { + continue; + } + const toolCall = part.tool_call; + if (toolCall.approval != null && !hasOutput(toolCall)) { + return true; + } + if (Array.isArray(toolCall.subagent_content)) { + stack.push(toolCall.subagent_content); + } + } + } + return false; +} + +/** Cuts at `end` without splitting a surrogate pair. */ +function sliceStart(text: string, end: number): string { + const code = text.charCodeAt(end - 1); + return text.slice(0, code >= 0xd800 && code <= 0xdbff ? end - 1 : end); +} + +/** Keeps the last `count` code units without starting inside a surrogate pair. */ +function sliceEnd(text: string, count: number): string { + if (count <= 0) { + return ''; + } + const start = text.length - count; + const code = text.charCodeAt(start); + return text.slice(code >= 0xdc00 && code <= 0xdfff ? start + 1 : start); +} + +/** + * Keeps the start and the end. Error prefixes and first error lines live at the start; + * validation feedback, the last line of a traceback and exit-status trailers live at the end. + * `minTailChars` widens the end to keep a trailer whole. + */ +export function previewOutputText(output: string, maxChars: number, minTailChars = 0): string { + if (output.length <= maxChars) { + return output; + } + const budget = Math.max(0, maxChars - TOOL_CALL_PREVIEW_ELISION.length); + const tailChars = Math.max(budget - Math.floor(budget * OUTPUT_HEAD_SHARE), minTailChars); + const headChars = Math.max(0, budget - tailChars); + if (headChars + tailChars + TOOL_CALL_PREVIEW_ELISION.length >= output.length) { + return output; + } + return `${sliceStart(output, headChars)}${TOOL_CALL_PREVIEW_ELISION}${sliceEnd(output, tailChars)}`; +} + +function capJsonStrings(value: JsonValue, maxChars: number): JsonValue { + if (typeof value === 'string') { + return value.length > maxChars ? `${sliceStart(value, maxChars)}…` : value; + } + if (Array.isArray(value)) { + return value.map((item) => capJsonStrings(item, maxChars)); + } + if (value == null || typeof value !== 'object') { + return value; + } + const result: { [key: string]: JsonValue } = {}; + for (const key in value) { + result[key] = capJsonStrings(value[key], maxChars); + } + return result; +} + +/** Counts string values, stopping once past `limit`. */ +function countJsonStrings(value: JsonValue, limit: number): number { + let count = 0; + const stack: JsonValue[] = [value]; + while (stack.length > 0 && count <= limit) { + const current = stack.pop() as JsonValue; + if (typeof current === 'string') { + count++; + } else if (Array.isArray(current)) { + for (const item of current) { + stack.push(item); + } + } else if (current != null && typeof current === 'object') { + for (const key in current) { + stack.push(current[key]); + } + } + } + return count; +} + +function parseJsonContainer(text: string): JsonValue | undefined { + const first = text.trimStart()[0]; + if (first !== '{' && first !== '[') { + return undefined; + } + try { + return JSON.parse(text) as JsonValue; + } catch { + return undefined; + } +} + +/** + * Serializes the JSON within `maxChars` by shortening only its longest strings: the largest + * per-string cap that fits wins, so short fields (a command, a path, an intent, a status) stay + * exact while a file body or log absorbs the cut. Every key, array item and scalar is kept, so + * readers that parse the value still find their fields. Returns `undefined` when even the + * shortest cap cannot fit, and the caller falls back to text. + */ +export function shrinkJsonToFit(value: JsonValue, maxChars: number): string | undefined { + const stringLimit = Math.floor(maxChars / MIN_JSON_STRING_CHARS); + if (countJsonStrings(value, stringLimit) > stringLimit) { + return undefined; + } + const capped = capJsonStrings(value, maxChars); + let low = MIN_JSON_STRING_CHARS; + let high = maxChars; + let best: string | undefined; + while (low <= high) { + const cap = Math.floor((low + high) / 2); + const serialized = JSON.stringify(capJsonStrings(capped, cap)); + if (serialized.length <= maxChars) { + best = serialized; + low = cap + 1; + } else { + high = cap - 1; + } + } + return best; +} + +type ArgsPreview = { args: string | JsonValue; length: number }; + +export function previewToolCallArgs(args: unknown, maxChars: number): ArgsPreview | undefined { + if (typeof args === 'string') { + if (args.length <= maxChars) { + return undefined; + } + const parsed = parseJsonContainer(args); + const shrunk = parsed === undefined ? undefined : shrinkJsonToFit(parsed, maxChars); + return { args: shrunk ?? sliceStart(args, maxChars), length: args.length }; + } + if (!isObject(args)) { + return undefined; + } + const serialized = JSON.stringify(args); + if (serialized.length <= maxChars) { + return undefined; + } + const shrunk = shrinkJsonToFit(args as JsonValue, maxChars); + return { + args: + shrunk === undefined ? sliceStart(serialized, maxChars) : (JSON.parse(shrunk) as JsonValue), + length: serialized.length, + }; +} + +/** + * Length of the exit-status trailer the attached-workspace `bash_tool` appends. Only a call the + * server stamped with that executor carries one, and its collapsed card reads the verdict there. + */ +function commandTrailerLength(toolCall: StoredToolCall, output: string): number { + if (toolCall.executor !== 'attached_workspace') { + return 0; + } + const match = COMMAND_RESULT_TRAILER.exec(output.slice(-MAX_TRAILER_CHARS)); + return match == null ? 0 : match[0].length; +} + +/** JSON output stays parseable (background-task results, handles); other text keeps both ends. */ +export function previewToolCallOutput( + toolCall: StoredToolCall, + output: string, + maxChars: number, +): string { + if (output.length <= maxChars) { + return output; + } + const parsed = parseJsonContainer(output); + const shrunk = parsed === undefined ? undefined : shrinkJsonToFit(parsed, maxChars); + return shrunk ?? previewOutputText(output, maxChars, commandTrailerLength(toolCall, output)); +} + +/** + * Returns the tool call with long content replaced by previews, or the same object when nothing + * needed shortening. Only settled calls change: a call still waiting on output, approval or a + * reader's answer keeps every field, since its card acts on them. + */ +export function previewToolCall( + toolCall: T, + limits: ToolCallPreviewLimits, +): T { + if (!isAgentToolCall(toolCall) || !hasOutput(toolCall)) { + return toolCall; + } + if (typeof toolCall.name === 'string' && FULL_CONTENT_TOOLS.has(toolCall.name)) { + return toolCall; + } + + const output = toolCall.output as string; + const outputPreview = previewToolCallOutput(toolCall, output, limits.outputChars); + const argsPreview = previewToolCallArgs(toolCall.args, limits.argsChars); + const subagentContent = toolCall.subagent_content; + const omitSubagentContent = + Array.isArray(subagentContent) && + subagentContent.length > 0 && + !hasPendingApproval(subagentContent); + + if (outputPreview === output && argsPreview == null && !omitSubagentContent) { + return toolCall; + } + + const next: T = { ...toolCall }; + if (outputPreview !== output) { + next.output = outputPreview; + next.outputTruncated = true; + next.outputLength = output.length; + } + if (argsPreview != null) { + next.args = argsPreview.args; + next.argsTruncated = true; + next.argsLength = argsPreview.length; + } + if (omitSubagentContent) { + delete next.subagent_content; + next.subagentContentOmitted = true; + next.subagentContentParts = (subagentContent as unknown[]).length; + } + return next; +} + +/** Previews every tool-call part of one content array; returns the same array when unchanged. */ +export function previewContentToolCalls( + content: unknown[], + limits: ToolCallPreviewLimits, +): unknown[] { + let result: unknown[] | undefined; + for (let i = 0; i < content.length; i++) { + const part = content[i]; + if (!isToolCallPart(part)) { + continue; + } + const toolCall = previewToolCall(part.tool_call, limits); + if (toolCall === part.tool_call) { + continue; + } + result ??= content.slice(); + result[i] = { ...part, tool_call: toolCall }; + } + return result ?? content; +} + +/** Applies previews across messages, copying only the messages whose content changed. */ +export function previewMessagesToolCalls( + messages: T[], + limits: ToolCallPreviewLimits, +): T[] { + let result: T[] | undefined; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (!Array.isArray(message?.content)) { + continue; + } + const content = previewContentToolCalls(message.content, limits); + if (content === message.content) { + continue; + } + result ??= messages.slice(); + result[i] = { ...message, content }; + } + return result ?? messages; +} + +/** True when the request asked for this preview format. */ +export function wantsToolCallPreviews(query: unknown): boolean { + if (!isObject(query)) { + return false; + } + return ( + (query as Record)[TOOL_CALL_PREVIEWS_PARAM] === TOOL_CALL_PREVIEWS_VERSION + ); +} + +export interface ToolCallPreviewRequest { + query?: unknown; + user?: AppConfigUserLike | null; +} + +export interface ToolCallPreviewDeps { + getAppConfig: (options: GetAppConfigOptions) => Promise; +} + +type MessagesPreviewer = (messages: T[]) => Promise; + +const sendInFull: MessagesPreviewer = (messages) => Promise.resolve(messages); + +async function resolveLimits( + req: ToolCallPreviewRequest, + deps: ToolCallPreviewDeps, +): Promise { + try { + const config = await deps.getAppConfig({ + ...getAppConfigOptionsFromUser(req.user), + skipRuntimeAugmentation: true, + }); + return config?.toolCallPreviews ?? toolCallPreviewsConfigSchema.parse({}); + } catch (error) { + logger.warn('[toolCallPreviews] Config unavailable; using default preview bounds', error); + return toolCallPreviewsConfigSchema.parse({}); + } +} + +/** + * Starts resolving the preview bounds for a message load, so the config read runs beside the + * message read instead of ahead of it. The returned function shapes the messages once both are + * in. A request without the preview parameter, or a deployment that turned previews off, + * receives the messages untouched. + */ +export function prepareToolCallPreviews( + req: ToolCallPreviewRequest, + deps: ToolCallPreviewDeps, +): MessagesPreviewer { + if (!wantsToolCallPreviews(req.query)) { + return sendInFull; + } + const limitsPromise = resolveLimits(req, deps); + return async (messages) => { + const limits = await limitsPromise; + if (!limits.enabled) { + return messages; + } + return previewMessagesToolCalls(messages, limits); + }; +} + +/** True when any tool call in the content, nested subagent runs included, carries preview markers. */ +export function containsToolCallPreviews(content: unknown): boolean { + if (!Array.isArray(content)) { + return false; + } + const stack: unknown[][] = [content]; + while (stack.length > 0) { + const current = stack.pop() ?? []; + for (const part of current) { + if (!isToolCallPart(part)) { + continue; + } + if (hasToolCallPreview(part.tool_call)) { + return true; + } + if (Array.isArray(part.tool_call.subagent_content)) { + stack.push(part.tool_call.subagent_content); + } + } + } + return false; +} + +interface MessageWriteRequest { + body?: { content?: unknown } | null; +} + +interface MessageWriteResponse { + status: (code: number) => { json: (body: { error: string }) => unknown }; +} + +/** + * Refuses a client-authored message whose content holds tool-call previews. A preview is a + * display copy; saving it would replace the stored output, arguments or transcript with it. + */ +export function rejectToolCallPreviewWrites( + req: MessageWriteRequest, + res: MessageWriteResponse, + next: () => void, +): void { + if (containsToolCallPreviews(req.body?.content)) { + res.status(400).json({ error: 'Tool call previews cannot be saved as message content' }); + return; + } + next(); +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 7b182030c88..94ae773e5f0 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -76,6 +76,17 @@ export const messages = (params: q.MessagesListParams) => { export const messagesArtifacts = (messageId: string) => `${messagesRoot}/artifact/${messageId}`; +/** One tool-call part of a stored message, in full. */ +export const messageToolCallPart = ({ + conversationId, + messageId, + partIndex, + toolCallId, +}: q.ToolCallPartParams) => { + const path = `${messagesRoot}/${encodeURIComponent(conversationId)}/${encodeURIComponent(messageId)}/parts/${partIndex}`; + return toolCallId ? `${path}?toolCallId=${encodeURIComponent(toolCallId)}` : path; +}; + export const messagesBranch = () => `${messagesRoot}/branch`; const shareRoot = `${BASE_URL}/api/share`; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 5fdaccf9516..a77527c4265 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -3693,6 +3693,24 @@ export const conversationListConfigSchema = z.object({ export type TConversationListConfig = z.infer; +/** + * Bounded previews of settled tool calls on conversation loads. A client that asks for them + * receives the start and end of each long output and argument string, without subagent + * transcripts, and fetches a part in full only when the reader opens it. `enabled: false` sends + * every load in full, as before previews existed. + */ +export const toolCallPreviewsConfigSchema = z.object({ + enabled: z.boolean().default(true), + /** Characters of `output` kept per tool call, half from the start and half from the end. + * JSON output stays valid JSON, and an exit-status trailer is always kept whole. */ + outputChars: z.number().int().min(256).max(1_000_000).default(512), + /** Characters of serialized `args` kept per tool call. JSON arguments stay valid JSON with + * every field present; only the longest strings are shortened. */ + argsChars: z.number().int().min(256).max(1_000_000).default(512), +}); + +export type TToolCallPreviewsConfig = z.infer; + export const configSchema = z.object({ version: z.string(), permissions: z.object({ maxWriteAttempts: permissionWriteAttemptsSchema }).optional(), @@ -3709,6 +3727,9 @@ export const configSchema = z.object({ conversationList: conversationListConfigSchema.default(() => conversationListConfigSchema.parse({}), ), + toolCallPreviews: toolCallPreviewsConfigSchema.default(() => + toolCallPreviewsConfigSchema.parse({}), + ), includedTools: z.array(z.string()).optional(), filteredTools: z.array(z.string()).optional(), mcpServers: MCPServersSchema.optional(), diff --git a/packages/data-provider/src/createPayload.spec.ts b/packages/data-provider/src/createPayload.spec.ts new file mode 100644 index 00000000000..29065582f2c --- /dev/null +++ b/packages/data-provider/src/createPayload.spec.ts @@ -0,0 +1,55 @@ +import type { TSubmission } from './types'; +import type { TMessage } from './schemas'; +import createPayload from './createPayload'; +import { EModelEndpoint } from './schemas'; + +const previewedResponse = { + messageId: 'response-1', + conversationId: 'convo-1', + parentMessageId: 'user-1', + isCreatedByUser: false, + text: '', + content: [ + { + type: 'tool_call', + tool_call: { + id: 'call_1', + name: 'bash_tool', + args: '{"command":"make"}', + output: 'head…tail', + outputTruncated: true, + outputLength: 40_000, + }, + }, + ], +} as unknown as TMessage; + +describe('createPayload', () => { + it.each([ + ['regenerate', { isRegenerate: true }], + ['continue', { isEdited: true, isContinued: true }], + ['edit and rerun', { isEdited: true, editedContent: { index: 0, type: 'text', text: 'x' } }], + ])('never carries cached tool-call content on %s', (_name, flags) => { + const submission = { + userMessage: { + messageId: 'user-1', + conversationId: 'convo-1', + parentMessageId: '00000000-0000-0000-0000-000000000000', + isCreatedByUser: true, + text: 'build it', + }, + conversation: { conversationId: 'convo-1', endpoint: EModelEndpoint.agents }, + endpointOption: { endpoint: EModelEndpoint.agents, agent_id: 'agent_1' }, + messages: [previewedResponse], + initialResponse: previewedResponse, + ...flags, + } as unknown as TSubmission; + + const { payload } = createPayload(submission); + const serialized = JSON.stringify(payload); + expect(serialized).not.toContain('tool_call'); + expect(serialized).not.toContain('outputTruncated'); + expect(payload).not.toHaveProperty('content'); + expect(payload).not.toHaveProperty('messages'); + }); +}); diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 6c4b14efa8a..73cf3ec970e 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -11,6 +11,7 @@ import type { ScheduleMCPConsentView, ConfirmScheduleMCPConsent } from './types/ import type { TFileConfig } from './file-config'; import type * as tl from './types/tools'; import type * as t from './types'; +import { TOOL_CALL_PREVIEWS_PARAM, TOOL_CALL_PREVIEWS_VERSION } from './previews'; import * as permissions from './accessPermissions'; import * as endpoints from './api-endpoints'; import { uploadEventStream } from './upload'; @@ -1162,14 +1163,29 @@ export function getOwnerMessageTexts( return request.post(`${endpoints.messages({ conversationId })}/owner-text`, { messageIds }); } -export function getMessagesByConvoId(conversationId: string): Promise { +/** + * Loads a conversation's messages. `toolPreviews` asks for bounded previews of settled tool + * calls; a caller that needs every byte (export, share, trace) leaves it off. + */ +export function getMessagesByConvoId( + conversationId: string, + options?: { toolPreviews?: boolean }, +): Promise { if ( conversationId === config.Constants.NEW_CONVO || conversationId === config.Constants.PENDING_CONVO ) { return Promise.resolve([]); } - return request.get(endpoints.messages({ conversationId })); + const url = endpoints.messages({ conversationId }); + if (options?.toolPreviews !== true) { + return request.get(url); + } + return request.get(`${url}?${TOOL_CALL_PREVIEWS_PARAM}=${TOOL_CALL_PREVIEWS_VERSION}`); +} + +export function getToolCallPart(params: q.ToolCallPartParams): Promise { + return request.get(endpoints.messageToolCallPart(params)); } export function getMessageById(conversationId: string, messageId: string): Promise { diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index df59360348e..2be4fec2157 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -11,6 +11,7 @@ export * from './file-config'; export * from './resolve-llm-delivery-path'; /* messages */ export * from './messages'; +export * from './previews'; export * from './errors'; /* run steps */ export * from './runSteps'; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index c7aa940a434..d5704fef521 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -1,5 +1,7 @@ export enum QueryKeys { messages = 'messages', + /* Full content of one tool-call part, fetched when its preview is not enough */ + toolCallPart = 'toolCallPart', ownerMessageTexts = 'ownerMessageTexts', sharedMessages = 'sharedMessages', sharedStartupConfig = 'sharedStartupConfig', diff --git a/packages/data-provider/src/previews.ts b/packages/data-provider/src/previews.ts new file mode 100644 index 00000000000..2e09ffbe230 --- /dev/null +++ b/packages/data-provider/src/previews.ts @@ -0,0 +1,44 @@ +import type { TMessageContentParts } from './types/content'; +import type { Agents } from './types/agents'; + +/** + * Query parameter a client adds to a conversation's message load to receive bounded previews of + * settled tool calls instead of their full output, arguments and subagent transcripts. A server + * that predates previews ignores it, and a client that omits it receives the full payload, so + * either side can upgrade first. + */ +export const TOOL_CALL_PREVIEWS_PARAM = 'toolPreviews'; + +/** The preview format this client understands; a later format bumps it. */ +export const TOOL_CALL_PREVIEWS_VERSION = '1'; + +/** Markers the server sets on a tool call whose content it shortened or left out. */ +export type ToolCallPreviewMarkers = Pick< + Agents.ToolCall, + | 'outputTruncated' + | 'outputLength' + | 'argsTruncated' + | 'argsLength' + | 'subagentContentOmitted' + | 'subagentContentParts' +>; + +/** A tool call as the full-part endpoint returns it, transcript included. */ +export type FullToolCall = Agents.ToolCall & { subagent_content?: TMessageContentParts[] }; + +/** True when any of the tool call's content is a preview rather than the stored value. */ +export function hasToolCallPreview(toolCall: ToolCallPreviewMarkers | null | undefined): boolean { + return ( + toolCall?.outputTruncated === true || + toolCall?.argsTruncated === true || + toolCall?.subagentContentOmitted === true + ); +} + +/** + * The exit-status trailer the attached-workspace `bash_tool` appends to its output + * (`formatCommandResult` in `packages/api/src/code/command.ts`), anchored to the end. Shared so + * the card that reads the verdict and the server that bounds previews agree on its extent. + */ +export const COMMAND_RESULT_TRAILER: RegExp = + /\n((?:\[(?:exit code: -?\d+|terminated by [\w+-]+|timed out|output truncated)\])+)((?:\nCommand reached timeoutMs: [^\n]*)?(?:\n\[directory hint: [^\n]*\])?)$/; diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index 2b7738fa826..df411bcbf48 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -121,6 +121,18 @@ export namespace Agents { generationId?: string; }; }; + /** The server sent a bounded preview of `output`; the full value loads on demand. */ + outputTruncated?: true; + /** Length of the full `output` in UTF-16 code units, present with `outputTruncated`. */ + outputLength?: number; + /** The server sent a bounded preview of `args`; the full value loads on demand. */ + argsTruncated?: true; + /** Length of the full serialized `args`, present with `argsTruncated`. */ + argsLength?: number; + /** The server left out `subagent_content`; the full value loads on demand. */ + subagentContentOmitted?: true; + /** Number of top-level parts in the omitted `subagent_content`. */ + subagentContentParts?: number; /** The tool call was rejected before execution because its input failed schema validation. */ inputValidationError?: true; /** Server-stamped provenance; see `PartMetadata.executor`. */ diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 4552b5fff59..200a0d3054a 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -1,5 +1,6 @@ import type { InfiniteData } from '@tanstack/react-query'; import type { RerankerTypes, SearchProviders, ScraperProviders } from '../config'; +import type { FullToolCall } from '../previews'; import type * as p from '../accessPermissions'; import type * as a from '../types/agents'; import type * as s from '../schemas'; @@ -110,6 +111,22 @@ export type MessagesListResponse = { nextCursor: string | null; }; +/** Locates one tool-call part. `toolCallId` disambiguates when the part moved. */ +export type ToolCallPartParams = { + conversationId: string; + messageId: string; + partIndex: number; + toolCallId?: string; +}; + +export type ToolCallPartResponse = { + conversationId: string; + messageId: string; + /** Index of the part in the stored message, which may differ from the requested one. */ + partIndex: number; + tool_call: FullToolCall; +}; + /* Shared Links */ export type SharedMessagesResponse = Omit & { messages: s.TMessage[]; diff --git a/packages/data-schemas/src/app/service.ts b/packages/data-schemas/src/app/service.ts index 3f1f01039be..924b674d2da 100644 --- a/packages/data-schemas/src/app/service.ts +++ b/packages/data-schemas/src/app/service.ts @@ -4,6 +4,7 @@ import { chatProjectsConfigSchema, filtersConfigSchema, conversationListConfigSchema, + toolCallPreviewsConfigSchema, hasActiveFiltersConfig, getConfigDefaults, langfuseConfigSchema, @@ -106,6 +107,18 @@ export function loadConversationListConfig( return conversationListConfigSchema.parse({}); } +/** Resolves the tool-call preview bounds; an invalid block keeps the defaults. */ +export function loadToolCallPreviewsConfig( + config: DeepPartial, +): NonNullable { + const parsed = toolCallPreviewsConfigSchema.safeParse(config.toolCallPreviews ?? {}); + if (parsed.success) { + return parsed.data; + } + logger.warn('[AppService] Invalid toolCallPreviews config', parsed.error.flatten()); + return toolCallPreviewsConfigSchema.parse({}); +} + export function loadFiltersConfig(config: DeepPartial): AppConfig['filters'] { const raw = config.filters; if (raw === undefined) { @@ -221,6 +234,7 @@ export const AppService = async (params?: { cloudfront: config.cloudfront as AppConfig['cloudfront'], secureImageLinks: config.secureImageLinks !== false, conversationList: loadConversationListConfig(config), + toolCallPreviews: loadToolCallPreviewsConfig(config), }; const agentsDefaults = agentsConfigSetup(config); diff --git a/packages/data-schemas/src/types/app.ts b/packages/data-schemas/src/types/app.ts index 32196ab9527..eb4782f98a3 100644 --- a/packages/data-schemas/src/types/app.ts +++ b/packages/data-schemas/src/types/app.ts @@ -15,6 +15,7 @@ import type { TAnthropicEndpoint, SummarizationConfig, TConversationListConfig, + TToolCallPreviewsConfig, SkillSyncConfig, FiltersConfig, } from 'librechat-data-provider'; @@ -118,6 +119,8 @@ export interface AppConfig { secureImageLinks?: TCustomConfig['secureImageLinks']; /** Validation limits for the conversation list's filter facets */ conversationList?: TConversationListConfig; + /** Bounds for the tool-call previews sent on conversation loads */ + toolCallPreviews?: TToolCallPreviewsConfig; /** Processed model specifications */ modelSpecs?: TCustomConfig['modelSpecs']; /** Available tools */ From a32216863f9f325c889ce3e80a6770a8ffc95ddf Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 17:50:03 -0400 Subject: [PATCH 02/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Validate=20Part=20R?= =?UTF-8?q?eads=20in=20Parallel=20and=20Preview=20Fork=20Responses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full-part read now starts beside conversation-access validation instead of after it, and keeps the active-job fallback the conversation read has, so a conversation still live before its first save can expand its previews. Fork, duplicate and shared-link fork responses honor the preview parameter, so they no longer seed the conversation cache with the full history. Background-task results whose structure cannot fit a valid JSON preview go whole, image cards keep their prompts, and the subagent panel offers a retry when its stored part fails to load. --- api/server/middleware/index.js | 2 + .../__test-utils__/convos-route-mocks.js | 1 + .../__tests__/messages-tool-previews.spec.js | 60 ++++++++++ api/server/routes/__tests__/share.spec.js | 1 + api/server/routes/convos.js | 5 +- api/server/routes/messages.js | 9 +- api/server/routes/share.js | 3 +- .../Subagents/SubagentThreadPanel.test.tsx | 16 ++- .../Chat/Subagents/SubagentThreadPanel.tsx | 22 +++- packages/api/src/messages/parts.spec.ts | 103 +++++++++++++++--- packages/api/src/messages/parts.ts | 51 +++++++-- packages/api/src/messages/previews.spec.ts | 71 ++++++++++++ packages/api/src/messages/previews.ts | 59 +++++++++- .../api/src/middleware/messageValidation.ts | 31 +++++- packages/data-provider/src/data-service.ts | 17 +-- 15 files changed, 403 insertions(+), 48 deletions(-) diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 774ee44a2bf..ca0ca7c367d 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -9,6 +9,7 @@ const validateEmailLogin = require('./validateEmailLogin'); const validateMessageReq = require('./validateMessageReq'); const { canReadActiveJobConversation, + createMessageRequestValidation, prepareMessageRequestValidation, sendValidationResponse, } = require('./messageValidation'); @@ -67,6 +68,7 @@ module.exports = { validateMessageReq, canReadActiveJobConversation, sendValidationResponse, + createMessageRequestValidation, prepareMessageRequestValidation, buildEndpointOption, validateRegistration, diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index 34fd583f6e5..bd86738d139 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -79,6 +79,7 @@ module.exports = { agents: () => ({ sleep: jest.fn() }), api: (overrides = {}) => ({ + withToolCallPreviews: (_req, result) => result, createGeneratedTitleHandler: jest.fn(() => jest.fn()), createRenameConversationHandler: jest.fn((deps) => { renameHandlerInputs.push(deps); diff --git a/api/server/routes/__tests__/messages-tool-previews.spec.js b/api/server/routes/__tests__/messages-tool-previews.spec.js index e9108cb66bc..a94c98794d3 100644 --- a/api/server/routes/__tests__/messages-tool-previews.spec.js +++ b/api/server/routes/__tests__/messages-tool-previews.spec.js @@ -231,6 +231,66 @@ describe('tool-call previews on the message routes', () => { expect(JSON.stringify(other.body)).not.toContain('compiled module'); }); + describe('a conversation still only live as an active job', () => { + const { GenerationJobManager } = require('@librechat/api'); + let liveConversationId; + + beforeEach(async () => { + liveConversationId = uuidv4(); + await mockDb.methods.saveMessage( + { userId: OWNER }, + { + messageId: 'live-response', + conversationId: liveConversationId, + isCreatedByUser: false, + content: assistantContent(), + }, + ); + }); + + afterEach(() => { + GenerationJobManager.getJob.mockResolvedValue(null); + }); + + const partUrl = () => `/api/messages/${liveConversationId}/live-response/parts/1`; + + it('serves the part to the job owner, as the conversation read does', async () => { + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId: OWNER }, + }); + const list = await request(app).get(`/api/messages/${liveConversationId}?toolPreviews=1`); + expect(list.status).toBe(200); + expect(list.body[0].content[1].tool_call.outputTruncated).toBe(true); + + const part = await request(app).get(partUrl()); + expect(part.status).toBe(200); + expect(part.body.tool_call.output).toBe(fullOutput); + }); + + it("refuses another user's job and a job from another tenant", async () => { + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId: OTHER }, + }); + expect((await request(app).get(partUrl())).status).toBe(404); + + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId: OWNER, tenantId: 'tenant-b' }, + }); + expect((await request(app).get(partUrl())).status).toBe(404); + }); + + it('refuses once the job is no longer active', async () => { + GenerationJobManager.getJob.mockResolvedValue({ + status: 'complete', + metadata: { userId: OWNER }, + }); + expect((await request(app).get(partUrl())).status).toBe(404); + }); + }); + it('does not serve parts of a durable child thread', async () => { const childId = uuidv4(); await mongoose.models.Conversation.create({ diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 060c463577a..49ae37e424a 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -78,6 +78,7 @@ const mockCreateShareContentPreflight = jest.fn((filters, options = {}) => { }); jest.mock('@librechat/api', () => ({ + withToolCallPreviews: (_req, result) => result, resolveDownloadPath: (file) => file.storageKey || file.filepath, assertModelBoundContent: (...args) => mockAssertModelBoundContent(...args), assertSharedFileMetadataAllowed: (...args) => mockAssertSharedFileMetadataAllowed(...args), diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index a8e346f4253..ebb02973b90 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -36,6 +36,7 @@ const { extractStoredMessageContent, GenerationJobManager, isStopConfirmed, + withToolCallPreviews, } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); @@ -800,7 +801,7 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, configMiddleware, async (re : { legacyPii: req.config.messageFilter.pii }), }); - res.json(result); + res.json(withToolCallPreviews(req, result)); } catch (error) { if (isContentFilterError(error)) { return res.status(error.statusCode).json(error.body); @@ -833,7 +834,7 @@ router.post( ? {} : { legacyPii: req.config.messageFilter.pii }), }); - res.status(201).json(result); + res.status(201).json(withToolCallPreviews(req, result)); } catch (error) { if (isContentFilterError(error)) { return res.status(error.statusCode).json(error.body); diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index dc31c412e3e..9515e3aa2c3 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -43,6 +43,7 @@ const { configMiddleware, sendValidationResponse, canReadActiveJobConversation, + createMessageRequestValidation, prepareMessageRequestValidation, } = require('~/server/middleware'); const db = require('~/models'); @@ -65,7 +66,11 @@ const filterFeedbackContent = createContentFilter({ extract: (req) => extractFeedbackContent(req.body), }); const toolCallPreviewDeps = { getAppConfig }; -const readToolCallPart = createToolCallPartHandler({ getMessages: db.getMessages }); +const readToolCallPart = createToolCallPartHandler({ + getMessages: db.getMessages, + validate: (req) => createMessageRequestValidation(req, { activeJobMessageRead: true }), + sendValidationResponse: (res, result) => sendValidationResponse(res, result), +}); const messageMutationMiddleware = [validateMessageReq, configMiddleware]; const storedMessageMutationMiddleware = [ validateMessageReq, @@ -619,7 +624,7 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) = } }); -router.get('/:conversationId/:messageId/parts/:partIndex', validateMessageReq, readToolCallPart); +router.get('/:conversationId/:messageId/parts/:partIndex', readToolCallPart); router.put('/:conversationId/:messageId', messageMutationMiddleware, async (req, res) => { try { diff --git a/api/server/routes/share.js b/api/server/routes/share.js index b64c278f705..5642eefa6f4 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -26,6 +26,7 @@ const { recordShareLinkRejection, traceIdForMessage, resolveDownloadPath, + withToolCallPreviews, } = require('@librechat/api'); const { logger, @@ -444,7 +445,7 @@ if (allowSharedLinks) { if (!result) { return res.status(404).json({ message: 'Shared conversation not found' }); } - return res.status(201).json(result); + return res.status(201).json(withToolCallPreviews(req, result)); } catch (error) { if (isContentFilterError(error)) { return res.status(error.statusCode).json(error.body); diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index eed8b2077d2..7bd505024d3 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -17,7 +17,9 @@ import { getDraft } from '~/utils'; const mockUseSubagentThreadQuery = jest.fn(); const mockUseToolCallPartQuery = jest.fn( - (..._args: unknown[]): { data?: unknown; isError: boolean } => ({ + ( + ..._args: unknown[] + ): { data?: unknown; isError: boolean; refetch?: () => Promise } => ({ data: undefined, isError: false, }), @@ -1573,8 +1575,13 @@ describe('SubagentThreadPanel', () => { expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'ready'); }); - it('reports a failed load instead of rendering the preview', () => { - mockUseToolCallPartQuery.mockImplementation(() => ({ data: undefined, isError: true })); + it('reports a failed load with a retry instead of rendering the preview', () => { + const refetch = jest.fn().mockResolvedValue(undefined); + mockUseToolCallPartQuery.mockImplementation(() => ({ + data: undefined, + isError: true, + refetch, + })); render( @@ -1584,6 +1591,9 @@ describe('SubagentThreadPanel', () => { expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'error'); expect(screen.queryByText('Shortened final ans…')).not.toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('com_ui_tool_content_error'); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_retry' })); + expect(refetch).toHaveBeenCalledTimes(1); }); it('reads nothing for a call that arrived in full', () => { diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index d9bcd4ab5fa..a28ad405da7 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -1330,11 +1330,27 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu )} ) : null; + const storedPartRetry = + awaitsStoredPart && storedToolCall == null && storedPart.isError ? ( +
+ {localize('com_ui_tool_content_error')} + +
+ ) : null; + const activityNotice = + droppedNotice == null && storedPartRetry == null ? null : ( + <> + {droppedNotice} + {storedPartRetry} + + ); let activityPanel: ReactNode; if (hasConversationProjection) { activityPanel = ( - {droppedNotice} + {activityNotice} {showUnavailableHistoryBoundary && (
- {droppedNotice} + {activityNotice}
{timelinePrefix} {visibleEventTasks.map(renderEventTask)} @@ -1409,7 +1425,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu state={panelState} showPrompt={false} headerInset - notice={droppedNotice} + notice={activityNotice} onCancelControl={ controlAvailable && !controlPending ? (controlId) => submitControl('cancel_message', controlId) diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index 850d3d1c470..c97217ed796 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -12,6 +12,8 @@ import { } from '@librechat/data-schemas'; import type { AllMethods, IMessage } from '@librechat/data-schemas'; import type { NextFunction, Request, Response } from 'express'; +import type { MessageValidationResult } from '~/middleware/messageValidation'; +import type { ToolCallPartDeps, ToolCallPartHandlerDeps } from './parts'; import { previewMessagesToolCalls } from './previews'; import { createToolCallPartHandler } from './parts'; @@ -107,6 +109,27 @@ function withUser(req: Request, _res: Response, next: NextFunction) { tenantStorage.run({ tenantId }, () => next()); } +const allow = (): Pick => ({ + validate: () => ({ + conversationId, + shouldFetchMessages: true, + promise: Promise.resolve({ ok: true }), + }), + sendValidationResponse: (res, result) => res.status(result.status).json(result.body), +}); + +const handler = ( + getMessages: ToolCallPartDeps['getMessages'], + validation: Partial = {}, +) => createToolCallPartHandler({ getMessages, ...allow(), ...validation }); + +const mount = (route: ReturnType) => { + const server = express(); + server.use(withUser); + server.get('/api/messages/:conversationId/:messageId/parts/:partIndex', route); + return server; +}; + const partUrl = (index: number | string, toolCallId?: string, convo = conversationId) => `/api/messages/${convo}/${messageId}/parts/${index}${ toolCallId == null ? '' : `?toolCallId=${encodeURIComponent(toolCallId)}` @@ -122,7 +145,7 @@ beforeAll(async () => { app.use(withUser); app.get( '/api/messages/:conversationId/:messageId/parts/:partIndex', - createToolCallPartHandler({ getMessages: methods.getMessages }), + handler((filter, select) => methods.getMessages(filter, select)), ); }); @@ -216,12 +239,7 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => it('rejects malformed coordinates before reading', async () => { const getMessages = jest.fn(); - const strict = express(); - strict.use(withUser); - strict.get( - '/api/messages/:conversationId/:messageId/parts/:partIndex', - createToolCallPartHandler({ getMessages }), - ); + const strict = mount(handler(getMessages)); expect((await request(strict).get(partUrl('-1'))).status).toBe(400); expect((await request(strict).get(partUrl('1.5'))).status).toBe(400); expect((await request(strict).get(partUrl('9999999'))).status).toBe(400); @@ -229,13 +247,72 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => expect(getMessages).not.toHaveBeenCalled(); }); - it('reports a read failure as a server error without leaking it', async () => { - const failing = express(); - failing.use(withUser); - failing.get( - '/api/messages/:conversationId/:messageId/parts/:partIndex', - createToolCallPartHandler({ getMessages: jest.fn().mockRejectedValue(new Error('db down')) }), + it('starts the part read beside access validation and answers only once it passes', async () => { + await seed(); + const events: string[] = []; + let release: (result: MessageValidationResult) => void = () => undefined; + const pending = new Promise((resolve) => (release = resolve)); + const server = mount( + handler( + (filter, select) => { + events.push('read'); + return methods.getMessages(filter, select); + }, + { validate: () => ({ conversationId, shouldFetchMessages: true, promise: pending }) }, + ), ); + const response = request(server) + .get(partUrl(1)) + .then((res) => res); + await new Promise((resolve) => setTimeout(resolve, 50)); + events.push('validated'); + release({ ok: true }); + expect((await response).status).toBe(200); + expect(events).toEqual(['read', 'validated']); + }); + + it('sends the validation verdict, never the part, when access is refused', async () => { + await seed(); + const server = mount( + handler((filter, select) => methods.getMessages(filter, select), { + validate: () => ({ + conversationId, + shouldFetchMessages: true, + promise: Promise.resolve({ + ok: false, + status: 403, + body: { error: 'User not authorized for this conversation' }, + }), + }), + }), + ); + const response = await request(server).get(partUrl(1)); + expect(response.status).toBe(403); + expect(JSON.stringify(response.body)).not.toContain('line of build output'); + }); + + it('answers not found for the placeholder conversation without reading', async () => { + const getMessages = jest.fn(); + const server = mount( + handler(getMessages, { + validate: () => ({ + conversationId: 'new', + shouldFetchMessages: false, + promise: Promise.resolve({ + ok: false, + status: 200, + body: [], + send: true, + }), + }), + }), + ); + expect((await request(server).get(partUrl(1))).status).toBe(404); + expect(getMessages).not.toHaveBeenCalled(); + }); + + it('reports a read failure as a server error without leaking it', async () => { + const failing = mount(handler(jest.fn().mockRejectedValue(new Error('db down')))); const response = await request(failing).get(partUrl(1)); expect(response.status).toBe(500); expect(response.body).toEqual({ error: 'Internal server error' }); diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index fd83db10886..157e6d71803 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -2,6 +2,10 @@ import { ContentTypes } from 'librechat-data-provider'; import { logger, CLIENT_MESSAGE_SELECT } from '@librechat/data-schemas'; import type { FullToolCall, ToolCallPartResponse } from 'librechat-data-provider'; import type { Request, Response } from 'express'; +import type { + MessageRequestValidation, + FailedMessageValidationResult, +} from '~/middleware/messageValidation'; /** Scopes a read to one message the authenticated user owns. */ export interface ToolCallPartFilter { @@ -100,12 +104,24 @@ export async function readToolCallPart( return { ok: true, value: located }; } -type ToolCallPartRequest = Request< +export type ToolCallPartRequest = Request< { conversationId?: string; messageId?: string; partIndex?: string }, ToolCallPartResponse | { error: string }, unknown, { toolCallId?: unknown } -> & { user?: { id?: string } }; +> & { user?: { id?: string; tenantId?: string | null } }; + +export interface ToolCallPartHandlerDeps extends ToolCallPartDeps { + /** + * Starts the conversation access check the message routes use (ownership, the active-job + * fallback, child-thread refusal). The part read runs beside it, and nothing is sent until it + * passes. + */ + validate: (req: ToolCallPartRequest) => MessageRequestValidation; + sendValidationResponse: (res: Response, result: FailedMessageValidationResult) => unknown; +} + +type SettledRead = { ok: true; value: ToolCallPartResult } | { ok: false; error: unknown }; function parsePartIndex(value: string | undefined): number | undefined { if (value == null || !PART_INDEX_PATTERN.test(value)) { @@ -127,13 +143,14 @@ function parseToolCallId(value: unknown): string | undefined | null { } /** - * `GET /api/messages/:conversationId/:messageId/parts/:partIndex[?toolCallId=]`. Mount it behind - * the same authentication and conversation-ownership validation as the message routes; the read - * itself is also scoped to the authenticated user, so a message id from another conversation or + * `GET /api/messages/:conversationId/:messageId/parts/:partIndex[?toolCallId=]`, mounted behind + * authentication. Conversation access is validated as the message routes do it, with the + * user-scoped part read started alongside so an expansion costs one round trip of latency; the + * read is itself scoped to the authenticated user, so a message id from another conversation or * account resolves to not found. */ export function createToolCallPartHandler( - deps: ToolCallPartDeps, + deps: ToolCallPartHandlerDeps, ): (req: ToolCallPartRequest, res: Response) => Promise { return async (req, res) => { const userId = req.user?.id; @@ -149,13 +166,31 @@ export function createToolCallPartHandler( return; } try { - const result = await readToolCallPart(deps, { + const validation = deps.validate(req); + if (!validation.shouldFetchMessages) { + res.status(404).json({ error: 'Tool call not found' }); + return; + } + const read: Promise = readToolCallPart(deps, { user: userId, conversationId, messageId, partIndex, toolCallId, - }); + }).then( + (value) => ({ ok: true, value }), + (error: unknown) => ({ ok: false, error }), + ); + const verdict = await validation.promise; + if (!verdict.ok) { + deps.sendValidationResponse(res, verdict); + return; + } + const settled = await read; + if (!settled.ok) { + throw settled.error; + } + const result = settled.value; if (!result.ok) { res.status(404).json({ error: 'Tool call not found' }); return; diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index f8a6f62a2b9..d9dbef66bf7 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -9,6 +9,7 @@ import { prepareToolCallPreviews, previewMessagesToolCalls, rejectToolCallPreviewWrites, + withToolCallPreviews, TOOL_CALL_PREVIEW_ELISION, } from './previews'; @@ -238,6 +239,37 @@ describe('previewToolCall', () => { expect(previewToolCall(toolCall, limits)).toBe(toolCall); }); + it('leaves image cards whole, since their details dialog shows the prompt directly', () => { + for (const name of ['image_gen_oai', 'image_edit_oai', 'gemini_image_gen']) { + const image = toolPart({ + name, + args: JSON.stringify({ prompt: 'p'.repeat(4_000) }), + output: 'o'.repeat(4_000), + }).tool_call; + expect(previewToolCall(image, limits)).toBe(image); + } + }); + + it('sends a background-task result whole when no valid JSON preview fits', () => { + const tasks = Array.from({ length: 200 }, (_, i) => ({ + task_id: `task_${i}`, + status: i === 7 ? 'error' : 'completed', + tool: 'bash_tool', + })); + const output = JSON.stringify({ + tasks, + partial: true, + warning: 'Some tasks are still running', + }); + const check = toolPart({ name: 'check_background_task', output }).tool_call; + expect(previewToolCall(check, limits).output).toBe(output); + + const other = toolPart({ name: 'list_issues_mcp_linear', output }).tool_call; + const otherPreview = previewToolCall(other, limits); + expect(otherPreview.outputTruncated).toBe(true); + expect(otherPreview.output?.length).toBeLessThanOrEqual(limits.outputChars); + }); + it('leaves the question-and-answer record and legacy Assistants calls whole', () => { const ask = toolPart({ name: 'ask_user_question', output: 'a'.repeat(5_000) }).tool_call; expect(previewToolCall(ask, limits)).toBe(ask); @@ -320,6 +352,45 @@ describe('prepareToolCallPreviews', () => { }); }); +describe('withToolCallPreviews', () => { + const result = () => ({ + conversation: { conversationId: 'fork-1' }, + messages: [{ messageId: 'm1', content: [toolPart({ output: 'o'.repeat(10_000) })] }], + }); + const config = (toolCallPreviews: Partial = {}) => ({ + toolCallPreviews: toolCallPreviewsConfigSchema.parse(toolCallPreviews), + }); + + it('previews a fork or duplicate response for a client that asked', () => { + const original = result(); + const previewed = withToolCallPreviews( + { query: { toolPreviews: '1' }, config: config() }, + original, + ); + expect(previewed).not.toBe(original); + expect(previewed.conversation).toBe(original.conversation); + expect(previewed.messages[0].content[0]).toMatchObject({ + tool_call: { outputTruncated: true }, + }); + expect(original.messages[0].content[0].tool_call.output).toHaveLength(10_000); + }); + + it('returns the response untouched otherwise', () => { + const original = result(); + expect(withToolCallPreviews({ query: {}, config: config() }, original)).toBe(original); + expect( + withToolCallPreviews( + { query: { toolPreviews: '1' }, config: config({ enabled: false }) }, + original, + ), + ).toBe(original); + const noMessages: { conversation: object; messages?: TestMessage[] } = { + conversation: { conversationId: 'x' }, + }; + expect(withToolCallPreviews({ query: { toolPreviews: '1' } }, noMessages)).toBe(noMessages); + }); +}); + describe('rejectToolCallPreviewWrites', () => { const run = (content: unknown) => { const json = jest.fn(); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index 76e38d42ce2..ed614e23cc4 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -1,5 +1,6 @@ import { logger } from '@librechat/data-schemas'; import { + Constants, ContentTypes, TOOL_CALL_PREVIEWS_PARAM, TOOL_CALL_PREVIEWS_VERSION, @@ -27,10 +28,25 @@ const MIN_JSON_STRING_CHARS = 8; const MAX_TRAILER_CHARS = 2_048; /** - * Tools whose cards render their full record without an expand step, so a preview would - * be what the reader sees: the question-and-answer record reads both fields directly. + * Tools whose cards show their full record without going through a card disclosure, so a + * preview would be what the reader sees: the question-and-answer record reads both fields + * directly, and an image card's details dialog shows the prompt from its arguments. */ -const FULL_CONTENT_TOOLS: ReadonlySet = new Set(['ask_user_question']); +const FULL_CONTENT_TOOLS: ReadonlySet = new Set([ + 'ask_user_question', + 'image_gen_oai', + 'image_edit_oai', + 'gemini_image_gen', +]); + +/** + * Tools whose collapsed card parses its JSON output for the row's verdict (task failures, + * partial results, warnings). When even a structure-preserving preview cannot fit, their output + * goes in full rather than as text that no longer parses. + */ +const PARSED_OUTPUT_TOOLS: ReadonlySet = new Set([ + Constants.CHECK_BACKGROUND_TASK as string, +]); type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; @@ -254,7 +270,17 @@ export function previewToolCallOutput( } const parsed = parseJsonContainer(output); const shrunk = parsed === undefined ? undefined : shrinkJsonToFit(parsed, maxChars); - return shrunk ?? previewOutputText(output, maxChars, commandTrailerLength(toolCall, output)); + if (shrunk != null) { + return shrunk; + } + if ( + parsed !== undefined && + typeof toolCall.name === 'string' && + PARSED_OUTPUT_TOOLS.has(toolCall.name) + ) { + return output; + } + return previewOutputText(output, maxChars, commandTrailerLength(toolCall, output)); } /** @@ -432,6 +458,31 @@ export function containsToolCallPreviews(content: unknown): boolean { return false; } +interface PreviewableResultRequest { + query?: unknown; + config?: { toolCallPreviews?: TToolCallPreviewsConfig } | null; +} + +/** + * Previews the messages a fork, duplicate or shared-link fork returns, so a client that asked + * for previews does not seed its conversation cache with the full history. Reads the bounds + * from the config the route already resolved. + */ +export function withToolCallPreviews( + req: PreviewableResultRequest, + result: T, +): T { + if (!wantsToolCallPreviews(req.query) || !Array.isArray(result?.messages)) { + return result; + } + const limits = req.config?.toolCallPreviews ?? toolCallPreviewsConfigSchema.parse({}); + if (!limits.enabled) { + return result; + } + const messages = previewMessagesToolCalls(result.messages, limits); + return messages === result.messages ? result : { ...result, messages }; +} + interface MessageWriteRequest { body?: { content?: unknown } | null; } diff --git a/packages/api/src/middleware/messageValidation.ts b/packages/api/src/middleware/messageValidation.ts index ba398497558..3ff6bab12a9 100644 --- a/packages/api/src/middleware/messageValidation.ts +++ b/packages/api/src/middleware/messageValidation.ts @@ -71,12 +71,25 @@ export type MessageValidationDeps = { logger: MessageValidationLogger; }; +export type MessageRequestValidationOptions = { + /** + * The route reads one stored message of the conversation (a tool-call part) rather than + * addressing a message for mutation, so a conversation that is still only an active job + * before its first save stays readable to the job's owner, as the conversation read is. + */ + activeJobMessageRead?: boolean; +}; + export type MessageRequestMiddleware = { canReadActiveJobConversation: ( req: MessageValidationRequest, conversationId?: string, + options?: MessageRequestValidationOptions, ) => Promise; - createMessageRequestValidation: (req: MessageValidationRequest) => MessageRequestValidation; + createMessageRequestValidation: ( + req: MessageValidationRequest, + options?: MessageRequestValidationOptions, + ) => MessageRequestValidation; prepareMessageRequestValidation: ( req: MessageValidationRequest, res: Response, @@ -105,8 +118,12 @@ export function createMessageRequestMiddleware( async function canReadActiveJobConversation( req: MessageValidationRequest, conversationId?: string, + options?: MessageRequestValidationOptions, ): Promise { - if (!isPublicReadMethod(req.method) || req.params?.messageId) { + if (!isPublicReadMethod(req.method)) { + return false; + } + if (req.params?.messageId && options?.activeJobMessageRead !== true) { return false; } @@ -143,11 +160,12 @@ export function createMessageRequestMiddleware( async function validateConversationAccess( req: MessageValidationRequest, conversationId?: string, + options?: MessageRequestValidationOptions, ): Promise { const conversation = await deps.getConvo(req.user.id, conversationId); if (!conversation) { - if (await canReadActiveJobConversation(req, conversationId)) { + if (await canReadActiveJobConversation(req, conversationId, options)) { return { ok: true }; } @@ -172,7 +190,10 @@ export function createMessageRequestMiddleware( return { ok: true }; } - function createMessageRequestValidation(req: MessageValidationRequest): MessageRequestValidation { + function createMessageRequestValidation( + req: MessageValidationRequest, + options?: MessageRequestValidationOptions, + ): MessageRequestValidation { const body = req.body ?? {}; const paramConversationId = req.params?.conversationId; const bodyConversationId = body.conversationId; @@ -207,7 +228,7 @@ export function createMessageRequestMiddleware( return { conversationId, shouldFetchMessages: true, - promise: validateConversationAccess(req, conversationId), + promise: validateConversationAccess(req, conversationId, options), }; } diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 73cf3ec970e..6fc8b077ff0 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -970,14 +970,20 @@ export const getCustomConfigSpeech = (): Promise /* conversations */ +/** Asks for tool-call previews in a response whose messages seed the conversation cache. */ +function withToolCallPreviews(url: string): string { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${TOOL_CALL_PREVIEWS_PARAM}=${TOOL_CALL_PREVIEWS_VERSION}`; +} + export function duplicateConversation( payload: t.TDuplicateConvoRequest, ): Promise { - return request.post(endpoints.duplicateConversation(), payload); + return request.post(withToolCallPreviews(endpoints.duplicateConversation()), payload); } export function forkConversation(payload: t.TForkConvoRequest): Promise { - return request.post(endpoints.forkConversation(), payload); + return request.post(withToolCallPreviews(endpoints.forkConversation()), payload); } export function forkSharedConversation( @@ -985,7 +991,7 @@ export function forkSharedConversation( targetMessageIndex?: number, shareRevision?: string, ): Promise { - return request.post(endpoints.forkSharedMessages(shareId), { + return request.post(withToolCallPreviews(endpoints.forkSharedMessages(shareId)), { targetMessageIndex, shareRevision, }); @@ -1178,10 +1184,7 @@ export function getMessagesByConvoId( return Promise.resolve([]); } const url = endpoints.messages({ conversationId }); - if (options?.toolPreviews !== true) { - return request.get(url); - } - return request.get(`${url}?${TOOL_CALL_PREVIEWS_PARAM}=${TOOL_CALL_PREVIEWS_VERSION}`); + return request.get(options?.toolPreviews === true ? withToolCallPreviews(url) : url); } export function getToolCallPart(params: q.ToolCallPartParams): Promise { From f7d8985352c83e0558b096a71b4a41dab539df75 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 18:09:19 -0400 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Key=20Hydrated=20Pa?= =?UTF-8?q?rts=20by=20Preview=20Revision=20and=20Call=20Identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fetched part is now cached under the preview's revision and replaces only the fields the server shortened, so a call whose stored output changes later (a harvested background result) is fetched again rather than rolled back. Part lookups carry the run-step and agent identity and refuse an ambiguous fallback, since provider tool-call ids can repeat within one response. A subagent that finished without final text has its transcript omitted like any other settled call, and JSON nested past a fixed depth falls back to a text preview; a call whose preview fails is sent whole instead of failing the conversation load. --- .../components/Chat/Messages/Content/Part.tsx | 11 +++- .../Messages/Content/Parts/SubagentCall.tsx | 7 ++- .../Content/__tests__/hydration.test.tsx | 62 +++++++++++++++++++ .../Chat/Messages/Content/hydration.tsx | 34 ++++++---- .../Subagents/SubagentThreadPanel.test.tsx | 13 ++-- .../Chat/Subagents/SubagentThreadPanel.tsx | 5 +- client/src/components/Chat/Subagents/state.ts | 11 +++- client/src/data-provider/Messages/queries.ts | 17 ++++- packages/api/src/messages/parts.spec.ts | 33 ++++++++++ packages/api/src/messages/parts.ts | 56 +++++++++++++---- packages/api/src/messages/previews.spec.ts | 24 +++++++ packages/api/src/messages/previews.ts | 59 +++++++++++++++--- packages/data-provider/src/api-endpoints.ts | 12 +++- packages/data-provider/src/previews.ts | 16 +++++ packages/data-provider/src/types/queries.ts | 4 ++ 15 files changed, 317 insertions(+), 47 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 552fc3f7e49..28eff1c6874 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -7,6 +7,7 @@ import { imageGenTools, isImageVisionTool, hasToolCallPreview, + getToolCallPreviewRevision, } from 'librechat-data-provider'; import type { TMessageContentParts, TAttachment, PartMetadata } from 'librechat-data-provider'; import { @@ -356,7 +357,15 @@ const Part = memo(function Part({ runStepStatus={toolCall.runStepStatus} attachments={attachments} persistedContent={persistedContent} - contentPreview={hasToolCallPreview(toolCall)} + contentPreview={ + hasToolCallPreview(toolCall) + ? { + revision: getToolCallPreviewRevision(toolCall), + stepId: toolCall.stepId, + agentId: part.agentId, + } + : undefined + } subagentIdentity={toolCall.subagentIdentity} hideAttachments={hideAttachments} /> diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 0ca00a3e044..66e8bd7644c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -8,6 +8,7 @@ import type { TMessage, TMessageContentParts, } from 'librechat-data-provider'; +import type { SubagentContentPreview } from '~/components/Chat/Subagents/state'; import type { SubagentTickerLine } from '~/utils/subagentContent'; import { activeSubagentPanel, @@ -52,7 +53,7 @@ interface SubagentCallProps { * field; those fall back to the atom (or the raw `output` string). */ persistedContent?: TMessageContentParts[]; /** The server sent this call as a preview; the panel loads the stored part when opened. */ - contentPreview?: boolean; + contentPreview?: SubagentContentPreview; subagentIdentity?: PartMetadata['subagentIdentity']; hideAttachments?: boolean; } @@ -174,7 +175,7 @@ export default function SubagentCall({ output, attachments, persistedContent, - contentPreview = false, + contentPreview, subagentIdentity, hideAttachments = false, }: SubagentCallProps) { @@ -340,7 +341,7 @@ export default function SubagentCall({ ...(prompt == null ? {} : { prompt }), ...(backgroundHandle == null ? { legacyOutput: output } : {}), ...(persistedContent == null ? {} : { persistedContent }), - ...(contentPreview ? { contentPreview: true as const } : {}), + ...(contentPreview == null ? {} : { contentPreview }), initialProgress, isSubmitting, ...(runStepStatus == null ? {} : { runStepStatus }), diff --git a/client/src/components/Chat/Messages/Content/__tests__/hydration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/hydration.test.tsx index f159e23d3d4..4d676549e8a 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/hydration.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/hydration.test.tsx @@ -182,7 +182,69 @@ describe('previewed tool-call parts', () => { }); }); +describe('previewed tool-call parts after the stored call changes', () => { + const getToolCallPart = dataService.getToolCallPart as jest.Mock; + + beforeEach(() => { + getToolCallPart.mockReset(); + }); + + it('fetches again when the preview it was fetched for is replaced', async () => { + getToolCallPart + .mockResolvedValueOnce(stored()) + .mockResolvedValueOnce(stored({ output: `${fullOutput}\nharvested` })); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const disclosures: ToolDisclosures = new Map(); + const tree = (part: TMessageContentParts) => ( + + + + + + + + {(rendered) => } + + + + + + + + ); + const { rerender } = render(tree(previewPart())); + await waitFor(() => expect(screen.getByTestId('output').textContent).toBe(fullOutput)); + + rerender( + tree( + previewPart({ + output: 'stdout:\nfull line\n…\nharvested', + outputLength: fullOutput.length + 10, + }), + ), + ); + await waitFor(() => + expect(screen.getByTestId('output').textContent).toBe(`${fullOutput}\nharvested`), + ); + expect(getToolCallPart).toHaveBeenCalledTimes(2); + }); +}); + describe('withFullToolCall', () => { + it('keeps fields the preview carried in full, so a fetched copy cannot roll them back', () => { + const part = previewPart({ output: 'current output', outputTruncated: undefined }); + const merged = withFullToolCall(part as never, { ...stored().tool_call, output: 'stale' }); + expect((merged.tool_call as { output: string }).output).toBe('current output'); + expect((merged.tool_call as { args: string }).args).toBe(fullArgs); + }); + it('replaces only content fields and drops the markers', () => { const part = previewPart({ subagentContentOmitted: true, subagentContentParts: 1 }); const transcript = [{ type: ContentTypes.TEXT, text: 'child' }] as TMessageContentParts[]; diff --git a/client/src/components/Chat/Messages/Content/hydration.tsx b/client/src/components/Chat/Messages/Content/hydration.tsx index 763b8cd388e..98e5118bb05 100644 --- a/client/src/components/Chat/Messages/Content/hydration.tsx +++ b/client/src/components/Chat/Messages/Content/hydration.tsx @@ -1,6 +1,10 @@ import { useCallback, useMemo, useState } from 'react'; import { Spinner } from '@librechat/client'; -import { ContentTypes, hasToolCallPreview } from 'librechat-data-provider'; +import { + ContentTypes, + hasToolCallPreview, + getToolCallPreviewRevision, +} from 'librechat-data-provider'; import type { FullToolCall, TMessageContentParts } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import { ToolContentRequestContext } from './disclosure'; @@ -20,17 +24,18 @@ export function isPreviewedToolCallPart(part: TMessageContentParts): part is Too } /** - * The part with its stored content in place of the preview. Only the content fields come from - * the server copy, and the preview markers go: everything else (progress, status, client-only - * state) stays as the conversation cache has it. + * The part with its stored content in place of the preview. Only the fields the server shortened + * come from the fetched copy, and the preview markers go: everything else (progress, status, + * fields the preview already carried in full, client-only state) stays as the conversation cache + * has it, so a fetched copy can never roll back a newer cached value. */ export function withFullToolCall(part: ToolCallPart, full: FullToolCall): ToolCallPart { const { - outputTruncated: _outputTruncated, + outputTruncated, outputLength: _outputLength, - argsTruncated: _argsTruncated, + argsTruncated, argsLength: _argsLength, - subagentContentOmitted: _subagentContentOmitted, + subagentContentOmitted, subagentContentParts: _subagentContentParts, ...toolCall } = part.tool_call as FullToolCall; @@ -38,9 +43,11 @@ export function withFullToolCall(part: ToolCallPart, full: FullToolCall): ToolCa ...part, tool_call: { ...toolCall, - args: full.args ?? toolCall.args, - output: full.output ?? toolCall.output, - ...(full.subagent_content != null ? { subagent_content: full.subagent_content } : {}), + ...(argsTruncated === true && full.args != null ? { args: full.args } : {}), + ...(outputTruncated === true && full.output != null ? { output: full.output } : {}), + ...(subagentContentOmitted === true && full.subagent_content != null + ? { subagent_content: full.subagent_content } + : {}), }, } as ToolCallPart; } @@ -61,16 +68,19 @@ export function PreviewedToolCallPart({ const { messageId, conversationId, partIndex } = useMessageContext(); const [requested, setRequested] = useState(false); const request = useCallback(() => setRequested(true), []); - const toolCallId = (part.tool_call as FullToolCall).id; + const toolCall = part.tool_call as FullToolCall; const canFetch = !!conversationId && !!messageId && partIndex != null; const query = useToolCallPartQuery( { conversationId: conversationId ?? '', messageId: messageId ?? '', partIndex: partIndex ?? 0, - toolCallId, + toolCallId: toolCall.id, + stepId: toolCall.stepId, + agentId: part.agentId, }, { enabled: requested && canFetch }, + getToolCallPreviewRevision(toolCall), ); const full = query.data?.tool_call; const effectivePart = useMemo( diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 7bd505024d3..289dc55a510 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -1510,7 +1510,7 @@ describe('SubagentThreadPanel', () => { subagentType: 'researcher', prompt: 'Review this change.', legacyOutput: 'Shortened final ans…', - contentPreview: true, + contentPreview: { revision: 'rev-1', stepId: 'step-9', agentId: 'agent-2' }, initialProgress: 1, isSubmitting: false, }; @@ -1541,8 +1541,11 @@ describe('SubagentThreadPanel', () => { messageId: 'parent-message', partIndex: 3, toolCallId: 'foreground-call', + stepId: 'step-9', + agentId: 'agent-2', }, { enabled: true }, + 'rev-1', ); expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'loading'); expect(screen.queryByText('Shortened final ans…')).not.toBeInTheDocument(); @@ -1602,9 +1605,11 @@ describe('SubagentThreadPanel', () => { , ); - expect(mockUseToolCallPartQuery).toHaveBeenCalledWith(expect.anything(), { - enabled: false, - }); + expect(mockUseToolCallPartQuery).toHaveBeenCalledWith( + expect.anything(), + { enabled: false }, + undefined, + ); }); }); diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index a28ad405da7..6e95f81b363 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -780,15 +780,18 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu /** A previewed call renders nothing until its stored part arrives: the preview's output is a * shortened copy and its transcript is absent, so neither may stand in for the activity. */ - const awaitsStoredPart = selection.contentPreview === true; + const awaitsStoredPart = selection.contentPreview != null; const storedPart = useToolCallPartQuery( { conversationId: selection.parentConversationId, messageId: selection.parentMessageId, partIndex: selection.partIndex, toolCallId: selection.toolCallId || undefined, + stepId: selection.contentPreview?.stepId, + agentId: selection.contentPreview?.agentId, }, { enabled: awaitsStoredPart }, + selection.contentPreview?.revision, ); const storedToolCall = awaitsStoredPart ? storedPart.data?.tool_call : undefined; const persistedContent = storedToolCall?.subagent_content ?? selection.persistedContent; diff --git a/client/src/components/Chat/Subagents/state.ts b/client/src/components/Chat/Subagents/state.ts index 4e47b30e3a2..3f396ec1bf5 100644 --- a/client/src/components/Chat/Subagents/state.ts +++ b/client/src/components/Chat/Subagents/state.ts @@ -284,6 +284,12 @@ const eventKey = (event: SubagentUpdateEvent): string | undefined => { return hashString(`${event.subagentRunId}\u0000${activityEventId}`); }; +export type SubagentContentPreview = { + revision: string; + stepId?: string; + agentId?: string; +}; + /** One child invocation selected for the shared read-only activity panel. */ export type ActiveSubagentPanel = { host: 'conversation' | 'share'; @@ -298,8 +304,9 @@ export type ActiveSubagentPanel = { legacyOutput?: string | null; persistedContent?: TMessageContentParts[]; /** The tool call came from the server as a preview: its transcript, output or arguments are - * shortened or absent, so the panel loads the stored part before rendering activity. */ - contentPreview?: true; + * shortened or absent, so the panel loads the stored part before rendering activity. Carries + * what identifies the stored part and the preview's revision. */ + contentPreview?: SubagentContentPreview; initialProgress: number; isSubmitting: boolean; runStepStatus?: PartMetadata['runStepStatus']; diff --git a/client/src/data-provider/Messages/queries.ts b/client/src/data-provider/Messages/queries.ts index de52163f5f7..7ba0bd54984 100644 --- a/client/src/data-provider/Messages/queries.ts +++ b/client/src/data-provider/Messages/queries.ts @@ -92,15 +92,26 @@ export function fetchConversationMessages(conversationId: string): Promise, + revision = '', ): QueryObserverResult => { - const { conversationId, messageId, partIndex, toolCallId } = params; + const { conversationId, messageId, partIndex, toolCallId, stepId, agentId } = params; return useQuery( - [QueryKeys.toolCallPart, conversationId, messageId, partIndex, toolCallId ?? ''], + [ + QueryKeys.toolCallPart, + conversationId, + messageId, + partIndex, + toolCallId ?? '', + stepId ?? '', + agentId ?? '', + revision, + ], () => dataService.getToolCallPart(params), { staleTime: Infinity, diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index c97217ed796..f5b450cc62a 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -209,6 +209,39 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => expect(response.body.tool_call.id).toBe('call_sub'); }); + it('tells repeated provider ids apart by step and agent, refusing an ambiguous match', async () => { + const repeated = (agentId: string, stepId: string, output: string) => ({ + type: ContentTypes.TOOL_CALL, + agentId, + tool_call: { + id: 'call_dup', + type: 'tool_call', + name: 'read_file', + stepId, + output, + args: '{}', + }, + }); + await seed(OWNER, { + content: [ + { type: ContentTypes.TEXT, text: 'two agents' }, + repeated('agent_a', 'step_a', 'from agent a'), + repeated('agent_b', 'step_b', 'from agent b'), + ], + }); + const byStep = await request(app).get(`${partUrl(0, 'call_dup')}&stepId=step_b`); + expect(byStep.status).toBe(200); + expect(byStep.body).toMatchObject({ partIndex: 2, tool_call: { output: 'from agent b' } }); + + const byAgent = await request(app).get(`${partUrl(0, 'call_dup')}&agentId=agent_a`); + expect(byAgent.body).toMatchObject({ partIndex: 1, tool_call: { output: 'from agent a' } }); + + const indexedMismatch = await request(app).get(`${partUrl(1, 'call_dup')}&stepId=step_b`); + expect(indexedMismatch.body).toMatchObject({ partIndex: 2 }); + + expect((await request(app).get(partUrl(0, 'call_dup'))).status).toBe(404); + }); + it('answers not found for a missing part, a non-tool part, or an unknown id', async () => { await seed(); expect((await request(app).get(partUrl(9))).status).toBe(404); diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index 157e6d71803..bdd6d0dc83d 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -29,8 +29,14 @@ export interface ToolCallPartDeps { export interface ToolCallPartInput extends ToolCallPartFilter { partIndex: number; toolCallId?: string; + /** Host run-step id of the call; provider tool-call ids can repeat within one response. */ + stepId?: string; + /** Agent that produced the part, for responses where parallel agents reuse call ids. */ + agentId?: string; } +type PartIdentity = Pick; + export type ToolCallPartErrorCode = 'message_not_found' | 'part_not_found'; export type ToolCallPartResult = @@ -39,6 +45,7 @@ export type ToolCallPartResult = interface StoredToolCallPart { type?: string; + agentId?: string; tool_call?: FullToolCall; } @@ -62,25 +69,46 @@ function getAgentToolCall(part: unknown): FullToolCall | undefined { * longer lines up with storage (a client-only card was inserted or removed). An id that matches * a different part than the index points at never wins over the indexed part. */ +function matchesIdentity(part: unknown, toolCall: FullToolCall, identity: PartIdentity): boolean { + if (identity.toolCallId != null && toolCall.id !== identity.toolCallId) { + return false; + } + if (identity.stepId != null && toolCall.stepId !== identity.stepId) { + return false; + } + return identity.agentId == null || (part as StoredToolCallPart).agentId === identity.agentId; +} + +/** + * Finds the part at its index when it carries the requested identity. Otherwise, when the + * client's copy no longer lines up with storage (a client-only card was inserted or removed), + * scans by identity and accepts only a single match: provider ids can repeat within a response, + * so an ambiguous match resolves to not found rather than to another call's content. + */ function locatePart( content: unknown[], partIndex: number, - toolCallId?: string, + identity: PartIdentity, ): { partIndex: number; toolCall: FullToolCall } | undefined { const indexed = getAgentToolCall(content[partIndex]); - if (indexed != null && (toolCallId == null || indexed.id === toolCallId)) { + if (indexed != null && matchesIdentity(content[partIndex], indexed, identity)) { return { partIndex, toolCall: indexed }; } - if (toolCallId == null) { + if (identity.toolCallId == null) { return undefined; } + let found: { partIndex: number; toolCall: FullToolCall } | undefined; for (let i = 0; i < content.length; i++) { const toolCall = getAgentToolCall(content[i]); - if (toolCall?.id === toolCallId) { - return { partIndex: i, toolCall }; + if (toolCall == null || !matchesIdentity(content[i], toolCall, identity)) { + continue; + } + if (found != null) { + return undefined; } + found = { partIndex: i, toolCall }; } - return undefined; + return found; } /** Reads one stored tool-call part in full, scoped to the requesting user's message. */ @@ -88,7 +116,7 @@ export async function readToolCallPart( deps: ToolCallPartDeps, input: ToolCallPartInput, ): Promise { - const { user, conversationId, messageId, partIndex, toolCallId } = input; + const { user, conversationId, messageId, partIndex, toolCallId, stepId, agentId } = input; const messages = await deps.getMessages( { user, conversationId, messageId }, CLIENT_MESSAGE_SELECT, @@ -97,7 +125,7 @@ export async function readToolCallPart( if (!Array.isArray(content)) { return { ok: false, error: { code: 'message_not_found' } }; } - const located = locatePart(content, partIndex, toolCallId); + const located = locatePart(content, partIndex, { toolCallId, stepId, agentId }); if (located == null) { return { ok: false, error: { code: 'part_not_found' } }; } @@ -108,7 +136,7 @@ export type ToolCallPartRequest = Request< { conversationId?: string; messageId?: string; partIndex?: string }, ToolCallPartResponse | { error: string }, unknown, - { toolCallId?: unknown } + { toolCallId?: unknown; stepId?: unknown; agentId?: unknown } > & { user?: { id?: string; tenantId?: string | null } }; export interface ToolCallPartHandlerDeps extends ToolCallPartDeps { @@ -132,7 +160,7 @@ function parsePartIndex(value: string | undefined): number | undefined { } /** `undefined` when absent, `null` when present but unusable. */ -function parseToolCallId(value: unknown): string | undefined | null { +function parseIdentifier(value: unknown): string | undefined | null { if (value == null) { return undefined; } @@ -156,12 +184,14 @@ export function createToolCallPartHandler( const userId = req.user?.id; const { conversationId, messageId } = req.params; const partIndex = parsePartIndex(req.params.partIndex); - const toolCallId = parseToolCallId(req.query.toolCallId); + const toolCallId = parseIdentifier(req.query.toolCallId); + const stepId = parseIdentifier(req.query.stepId); + const agentId = parseIdentifier(req.query.agentId); if (!userId || !conversationId || !messageId) { res.status(404).json({ error: 'Tool call not found' }); return; } - if (partIndex == null || toolCallId === null) { + if (partIndex == null || toolCallId === null || stepId === null || agentId === null) { res.status(400).json({ error: 'Invalid tool call part' }); return; } @@ -177,6 +207,8 @@ export function createToolCallPartHandler( messageId, partIndex, toolCallId, + stepId, + agentId, }).then( (value) => ({ ok: true, value }), (error: unknown) => ({ ok: false, error }), diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index d9dbef66bf7..591461de06a 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -23,6 +23,7 @@ type TestToolCall = { output?: string; executor?: string; progress?: number; + runStepStatus?: string; approval?: unknown; subagent_content?: unknown[]; outputTruncated?: true; @@ -233,6 +234,20 @@ describe('previewToolCall', () => { expect(previewToolCall(toolCall, limits)).toBe(toolCall); }); + it('omits the transcript of a subagent that finished without final text', () => { + const transcript = [{ type: ContentTypes.TEXT, text: 'searched and found nothing' }]; + for (const finished of [{ progress: 1 }, { runStepStatus: 'completed' }]) { + const toolCall = toolPart({ + name: 'subagent', + subagent_content: transcript, + ...finished, + }).tool_call; + const preview = previewToolCall(toolCall, limits); + expect(preview).toMatchObject({ subagentContentOmitted: true, subagentContentParts: 1 }); + expect(preview).not.toHaveProperty('outputTruncated'); + } + }); + it('keeps a subagent transcript while the subagent is still running', () => { const transcript = [{ type: ContentTypes.TEXT, text: 'working' }]; const toolCall = toolPart({ name: 'subagent', subagent_content: transcript }).tool_call; @@ -278,6 +293,15 @@ describe('previewToolCall', () => { }); }); +describe('deeply nested JSON output', () => { + it('falls back to a bounded text preview instead of exhausting the stack', () => { + const output = `${'['.repeat(20_000)}${']'.repeat(20_000)}`; + const preview = previewToolCall(toolPart({ output }).tool_call, limits); + expect(preview.outputTruncated).toBe(true); + expect(preview.output?.length).toBeLessThanOrEqual(limits.outputChars); + }); +}); + describe('previewMessagesToolCalls', () => { it('copies only the messages and parts that change, keeping content positions', () => { const untouched = { messageId: 'm1', content: [{ type: ContentTypes.TEXT, text: 'hello' }] }; diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index ed614e23cc4..8590e962056 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -24,6 +24,9 @@ const OUTPUT_HEAD_SHARE = 0.5; /** Shortest a string inside a JSON preview gets before the structure is abandoned for text. */ const MIN_JSON_STRING_CHARS = 8; +/** Deepest JSON nesting a structure-preserving preview walks; deeper values fall back to text. */ +const MAX_JSON_DEPTH = 64; + /** Ceiling on a kept exit-status trailer, so a malformed one cannot defeat the bound. */ const MAX_TRAILER_CHARS = 2_048; @@ -55,6 +58,8 @@ interface StoredToolCall extends ToolCallPreviewMarkers { type?: string; name?: string; executor?: string; + progress?: unknown; + runStepStatus?: unknown; output?: unknown; args?: unknown; approval?: unknown; @@ -87,6 +92,22 @@ const isAgentToolCall = (toolCall: StoredToolCall): boolean => const hasOutput = (toolCall: StoredToolCall): boolean => typeof toolCall.output === 'string' && toolCall.output.length > 0; +/** + * A call whose content no card still acts on: it has output, or its run step closed or reached + * full progress, and it is not waiting on an approval. A subagent can finish with no final text, + * and its transcript is as large as any other. + */ +const isSettled = (toolCall: StoredToolCall): boolean => { + if (toolCall.approval != null && !hasOutput(toolCall)) { + return false; + } + return ( + hasOutput(toolCall) || + toolCall.runStepStatus != null || + (typeof toolCall.progress === 'number' && toolCall.progress >= 1) + ); +}; + /** An unresolved approval anywhere in the parts, including inside nested subagent runs. */ function hasPendingApproval(parts: unknown[]): boolean { const stack: unknown[][] = [parts]; @@ -159,21 +180,27 @@ function capJsonStrings(value: JsonValue, maxChars: number): JsonValue { return result; } -/** Counts string values, stopping once past `limit`. */ +/** + * Counts string values, stopping once past `limit`. Nesting deeper than `MAX_JSON_DEPTH` counts + * as unfit, since the recursive cap and serialization that follow would exhaust the stack. + */ function countJsonStrings(value: JsonValue, limit: number): number { let count = 0; - const stack: JsonValue[] = [value]; + const stack: Array<[JsonValue, number]> = [[value, 0]]; while (stack.length > 0 && count <= limit) { - const current = stack.pop() as JsonValue; + const [current, depth] = stack.pop() as [JsonValue, number]; + if (depth > MAX_JSON_DEPTH) { + return Infinity; + } if (typeof current === 'string') { count++; } else if (Array.isArray(current)) { for (const item of current) { - stack.push(item); + stack.push([item, depth + 1]); } } else if (current != null && typeof current === 'object') { for (const key in current) { - stack.push(current[key]); + stack.push([current[key], depth + 1]); } } } @@ -292,14 +319,14 @@ export function previewToolCall( toolCall: T, limits: ToolCallPreviewLimits, ): T { - if (!isAgentToolCall(toolCall) || !hasOutput(toolCall)) { + if (!isAgentToolCall(toolCall) || !isSettled(toolCall)) { return toolCall; } if (typeof toolCall.name === 'string' && FULL_CONTENT_TOOLS.has(toolCall.name)) { return toolCall; } - const output = toolCall.output as string; + const output = typeof toolCall.output === 'string' ? toolCall.output : ''; const outputPreview = previewToolCallOutput(toolCall, output, limits.outputChars); const argsPreview = previewToolCallArgs(toolCall.args, limits.argsChars); const subagentContent = toolCall.subagent_content; @@ -331,6 +358,22 @@ export function previewToolCall( return next; } +/** + * A call that cannot be previewed is sent as stored rather than failing the whole conversation + * load; previews are an optimization, and the full value is what the client would otherwise get. + */ +function previewToolCallSafely( + toolCall: T, + limits: ToolCallPreviewLimits, +): T { + try { + return previewToolCall(toolCall, limits); + } catch (error) { + logger.warn('[toolCallPreviews] Sending a tool call in full; its preview failed', error); + return toolCall; + } +} + /** Previews every tool-call part of one content array; returns the same array when unchanged. */ export function previewContentToolCalls( content: unknown[], @@ -342,7 +385,7 @@ export function previewContentToolCalls( if (!isToolCallPart(part)) { continue; } - const toolCall = previewToolCall(part.tool_call, limits); + const toolCall = previewToolCallSafely(part.tool_call, limits); if (toolCall === part.tool_call) { continue; } diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 94ae773e5f0..7432e3e506c 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -82,9 +82,19 @@ export const messageToolCallPart = ({ messageId, partIndex, toolCallId, + stepId, + agentId, }: q.ToolCallPartParams) => { const path = `${messagesRoot}/${encodeURIComponent(conversationId)}/${encodeURIComponent(messageId)}/parts/${partIndex}`; - return toolCallId ? `${path}?toolCallId=${encodeURIComponent(toolCallId)}` : path; + const query = [ + ['toolCallId', toolCallId], + ['stepId', stepId], + ['agentId', agentId], + ] + .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== '') + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join('&'); + return query === '' ? path : `${path}?${query}`; }; export const messagesBranch = () => `${messagesRoot}/branch`; diff --git a/packages/data-provider/src/previews.ts b/packages/data-provider/src/previews.ts index 2e09ffbe230..53300f30f81 100644 --- a/packages/data-provider/src/previews.ts +++ b/packages/data-provider/src/previews.ts @@ -26,6 +26,22 @@ export type ToolCallPreviewMarkers = Pick< /** A tool call as the full-part endpoint returns it, transcript included. */ export type FullToolCall = Agents.ToolCall & { subagent_content?: TMessageContentParts[] }; +/** + * Identifies the server's current preview of a call. It changes when the stored call does (a + * background result replacing its output, a transcript growing), so a client cache of the full + * part keyed on it never outlives the content it was fetched for. + */ +export function getToolCallPreviewRevision(toolCall: FullToolCall): string { + const output = typeof toolCall.output === 'string' ? toolCall.output : ''; + return [ + output.length, + toolCall.outputLength ?? '', + toolCall.argsLength ?? '', + toolCall.subagentContentParts ?? '', + output.slice(-48), + ].join(':'); +} + /** True when any of the tool call's content is a preview rather than the stored value. */ export function hasToolCallPreview(toolCall: ToolCallPreviewMarkers | null | undefined): boolean { return ( diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index 200a0d3054a..c46750374a8 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -117,6 +117,10 @@ export type ToolCallPartParams = { messageId: string; partIndex: number; toolCallId?: string; + /** Host run-step id; provider tool-call ids can repeat within one response. */ + stepId?: string; + /** Agent that produced the part, when parallel agents share a response. */ + agentId?: string; }; export type ToolCallPartResponse = { From 7c9b3c8cddfedd940d870a6bdb8cea01d33a61f5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 18:23:18 -0400 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Release=20Hydrated?= =?UTF-8?q?=20Parts=20With=20Their=20History=20and=20Bound=20JSON=20Work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full tool-call parts fetched for a conversation are removed when its message history is released or cleared, so they cannot outlive it under their own key. The structure-preserving JSON preview now bails out before cloning when the value has more nodes or strings than could ever fit, keeps a __proto__ key as ordinary data, and the part endpoint accepts any safe-integer index instead of an arbitrary ceiling. --- .../Messages/__tests__/retention.spec.ts | 29 +++++++++++++++ .../src/data-provider/Messages/retention.ts | 12 +++++++ packages/api/src/messages/parts.spec.ts | 3 +- packages/api/src/messages/parts.ts | 5 ++- packages/api/src/messages/previews.spec.ts | 21 +++++++++++ packages/api/src/messages/previews.ts | 35 ++++++++++++------- 6 files changed, 89 insertions(+), 16 deletions(-) diff --git a/client/src/data-provider/Messages/__tests__/retention.spec.ts b/client/src/data-provider/Messages/__tests__/retention.spec.ts index 4f6e1f800c9..9b89506f86b 100644 --- a/client/src/data-provider/Messages/__tests__/retention.spec.ts +++ b/client/src/data-provider/Messages/__tests__/retention.spec.ts @@ -244,6 +244,35 @@ describe('retainMessages', () => { void pending.catch(() => undefined); }); + it('releases the full tool-call parts fetched for a conversation along with its history', () => { + const part = (conversationId: string, index: number) => [ + QueryKeys.toolCallPart, + conversationId, + `${conversationId}-1`, + index, + 'call', + '', + '', + 'rev', + ]; + const isPartCached = (key: unknown[]) => queryClient.getQueryCache().find(key) != null; + visit(queryClient, 'a'); + queryClient.setQueryData(part('a', 1), { tool_call: { output: 'full' } }); + queryClient.setQueryData(part('b', 1), { tool_call: { output: 'other conversation' } }); + seed(queryClient, 'b'); + const stillOpen = observe(queryClient, 'b'); + + jest.advanceTimersByTime(TTL); + expect(isCached(queryClient, 'a')).toBe(false); + expect(isPartCached(part('a', 1))).toBe(false); + expect(isPartCached(part('b', 1))).toBe(true); + + queryClient.setQueryData(part('b', 2), { tool_call: { output: 'cleared' } }); + queryClient.removeQueries([QueryKeys.messages, 'b'], { exact: true }); + expect(isPartCached(part('b', 2))).toBe(false); + stillOpen(); + }); + it('restores the messages query defaults on cleanup', () => { expect(queryClient.getQueryDefaults([QueryKeys.messages])?.cacheTime).toBe(Infinity); stop(); diff --git a/client/src/data-provider/Messages/retention.ts b/client/src/data-provider/Messages/retention.ts index 4ec01293257..c868fad15ab 100644 --- a/client/src/data-provider/Messages/retention.ts +++ b/client/src/data-provider/Messages/retention.ts @@ -163,6 +163,15 @@ export function retainMessages( }); } + /** Full tool-call parts fetched for a conversation's previews leave with its history, so a + * released conversation does not keep its largest content alive under another key. */ + const releaseToolCallParts = (conversationId: string) => { + queryClient.removeQueries({ + queryKey: [QueryKeys.toolCallPart, conversationId], + predicate: (query) => query.getObserversCount() === 0, + }); + }; + const unsubscribe = cache.subscribe((event) => { if ( event.type !== 'added' && @@ -180,6 +189,9 @@ export function retainMessages( schedule(RELEASE_SETTLE_MS); return; } + if (event.type === 'removed') { + releaseToolCallParts(conversationId); + } if (event.type !== 'observerRemoved') { leftAt.delete(conversationId); return; diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index f5b450cc62a..bd19c2f9909 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -245,6 +245,7 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => it('answers not found for a missing part, a non-tool part, or an unknown id', async () => { await seed(); expect((await request(app).get(partUrl(9))).status).toBe(404); + expect((await request(app).get(partUrl(250_000))).status).toBe(404); expect((await request(app).get(partUrl(0))).status).toBe(404); expect((await request(app).get(partUrl(1, 'call_missing'))).status).toBe(404); }); @@ -275,7 +276,7 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => const strict = mount(handler(getMessages)); expect((await request(strict).get(partUrl('-1'))).status).toBe(400); expect((await request(strict).get(partUrl('1.5'))).status).toBe(400); - expect((await request(strict).get(partUrl('9999999'))).status).toBe(400); + expect((await request(strict).get(partUrl('99999999999999999999'))).status).toBe(400); expect((await request(strict).get(partUrl(1, 'x'.repeat(600)))).status).toBe(400); expect(getMessages).not.toHaveBeenCalled(); }); diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index bdd6d0dc83d..417a5529145 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -49,9 +49,8 @@ interface StoredToolCallPart { tool_call?: FullToolCall; } -const MAX_PART_INDEX = 100_000; const MAX_TOOL_CALL_ID_LENGTH = 512; -const PART_INDEX_PATTERN = /^\d{1,6}$/; +const PART_INDEX_PATTERN = /^\d+$/; function getAgentToolCall(part: unknown): FullToolCall | undefined { if (part == null || typeof part !== 'object') { @@ -156,7 +155,7 @@ function parsePartIndex(value: string | undefined): number | undefined { return undefined; } const index = Number(value); - return index <= MAX_PART_INDEX ? index : undefined; + return Number.isSafeInteger(index) ? index : undefined; } /** `undefined` when absent, `null` when present but unusable. */ diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index 591461de06a..db5bade18b8 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -293,6 +293,27 @@ describe('previewToolCall', () => { }); }); +describe('wide or unusual JSON', () => { + it('falls back to text for a wide array without cloning it', () => { + const output = JSON.stringify(Array.from({ length: 200_000 }, (_, i) => i)); + const stringify = jest.spyOn(JSON, 'stringify'); + const preview = previewToolCall(toolPart({ output }).tool_call, limits); + expect(stringify).not.toHaveBeenCalled(); + stringify.mockRestore(); + expect(preview.outputTruncated).toBe(true); + expect(preview.output?.length).toBeLessThanOrEqual(limits.outputChars); + }); + + it('keeps a __proto__ key as ordinary data', () => { + const args = `{"__proto__":{"mode":"x"},"pad":"${'p'.repeat(4_000)}"}`; + const preview = previewToolCallArgs(args, 512); + const parsed = JSON.parse(preview?.args as string); + expect(Object.prototype.hasOwnProperty.call(parsed, '__proto__')).toBe(true); + expect(parsed.__proto__).toEqual({ mode: 'x' }); + expect(Object.prototype.hasOwnProperty.call(parsed, 'mode')).toBe(false); + }); +}); + describe('deeply nested JSON output', () => { it('falls back to a bounded text preview instead of exhausting the stack', () => { const output = `${'['.repeat(20_000)}${']'.repeat(20_000)}`; diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index 8590e962056..1b1e3a38871 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -173,7 +173,8 @@ function capJsonStrings(value: JsonValue, maxChars: number): JsonValue { if (value == null || typeof value !== 'object') { return value; } - const result: { [key: string]: JsonValue } = {}; + /** Null-prototype, so a `__proto__` key stays an ordinary data key. */ + const result: { [key: string]: JsonValue } = Object.create(null); for (const key in value) { result[key] = capJsonStrings(value[key], maxChars); } @@ -181,19 +182,30 @@ function capJsonStrings(value: JsonValue, maxChars: number): JsonValue { } /** - * Counts string values, stopping once past `limit`. Nesting deeper than `MAX_JSON_DEPTH` counts - * as unfit, since the recursive cap and serialization that follow would exhaust the stack. + * Whether the JSON could fit `maxChars` once its strings are capped: every value serializes to + * at least two characters with its separator, and every string to at least + * `MIN_JSON_STRING_CHARS`, so a structure with more values or strings than that allows cannot + * fit at any cap. The walk is iterative, stops as soon as either bound is passed, and treats + * nesting deeper than `MAX_JSON_DEPTH` as unfit, so the cloning and serialization that follow + * only ever run on small, shallow values. */ -function countJsonStrings(value: JsonValue, limit: number): number { - let count = 0; +function mayFitJson(value: JsonValue, maxChars: number): boolean { + const maxNodes = Math.floor(maxChars / 2); + const maxStrings = Math.floor(maxChars / MIN_JSON_STRING_CHARS); + let nodes = 0; + let strings = 0; const stack: Array<[JsonValue, number]> = [[value, 0]]; - while (stack.length > 0 && count <= limit) { + while (stack.length > 0) { const [current, depth] = stack.pop() as [JsonValue, number]; - if (depth > MAX_JSON_DEPTH) { - return Infinity; + nodes++; + if (nodes > maxNodes || depth > MAX_JSON_DEPTH) { + return false; } if (typeof current === 'string') { - count++; + strings++; + if (strings > maxStrings) { + return false; + } } else if (Array.isArray(current)) { for (const item of current) { stack.push([item, depth + 1]); @@ -204,7 +216,7 @@ function countJsonStrings(value: JsonValue, limit: number): number { } } } - return count; + return true; } function parseJsonContainer(text: string): JsonValue | undefined { @@ -227,8 +239,7 @@ function parseJsonContainer(text: string): JsonValue | undefined { * shortest cap cannot fit, and the caller falls back to text. */ export function shrinkJsonToFit(value: JsonValue, maxChars: number): string | undefined { - const stringLimit = Math.floor(maxChars / MIN_JSON_STRING_CHARS); - if (countJsonStrings(value, stringLimit) > stringLimit) { + if (!mayFitJson(value, maxChars)) { return undefined; } const capped = capJsonStrings(value, maxChars); From 261aa5c1a21c876e42fbabc0122ecdba93c1b730 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 18:31:53 -0400 Subject: [PATCH 05/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Count=20Property=20?= =?UTF-8?q?Names=20Before=20Cloning=20a=20JSON=20Preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Property names are never shortened, so the pre-check now adds their length to its budget and falls back to the text preview before any cloning when they alone could not fit. --- packages/api/src/messages/previews.spec.ts | 10 ++++++++++ packages/api/src/messages/previews.ts | 11 ++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index db5bade18b8..31a968b6c19 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -304,6 +304,16 @@ describe('wide or unusual JSON', () => { expect(preview.output?.length).toBeLessThanOrEqual(limits.outputChars); }); + it('falls back to text for an oversized property name without cloning it', () => { + const output = `{"${'k'.repeat(200_000)}":1}`; + const stringify = jest.spyOn(JSON, 'stringify'); + const preview = previewToolCall(toolPart({ output }).tool_call, limits); + expect(stringify).not.toHaveBeenCalled(); + stringify.mockRestore(); + expect(preview.outputTruncated).toBe(true); + expect(preview.output?.length).toBeLessThanOrEqual(limits.outputChars); + }); + it('keeps a __proto__ key as ordinary data', () => { const args = `{"__proto__":{"mode":"x"},"pad":"${'p'.repeat(4_000)}"}`; const preview = previewToolCallArgs(args, 512); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index 1b1e3a38871..e62dda89fef 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -183,9 +183,9 @@ function capJsonStrings(value: JsonValue, maxChars: number): JsonValue { /** * Whether the JSON could fit `maxChars` once its strings are capped: every value serializes to - * at least two characters with its separator, and every string to at least - * `MIN_JSON_STRING_CHARS`, so a structure with more values or strings than that allows cannot - * fit at any cap. The walk is iterative, stops as soon as either bound is passed, and treats + * at least two characters with its separator, every string to at least `MIN_JSON_STRING_CHARS`, + * and property names are never shortened, so a structure with more values, strings or key + * characters than that allows cannot fit at any cap. The walk is iterative, stops as soon as either bound is passed, and treats * nesting deeper than `MAX_JSON_DEPTH` as unfit, so the cloning and serialization that follow * only ever run on small, shallow values. */ @@ -194,6 +194,7 @@ function mayFitJson(value: JsonValue, maxChars: number): boolean { const maxStrings = Math.floor(maxChars / MIN_JSON_STRING_CHARS); let nodes = 0; let strings = 0; + let keyChars = 0; const stack: Array<[JsonValue, number]> = [[value, 0]]; while (stack.length > 0) { const [current, depth] = stack.pop() as [JsonValue, number]; @@ -212,6 +213,10 @@ function mayFitJson(value: JsonValue, maxChars: number): boolean { } } else if (current != null && typeof current === 'object') { for (const key in current) { + keyChars += key.length + 3; + if (keyChars > maxChars) { + return false; + } stack.push([current[key], depth + 1]); } } From c7b5447b9aef5bd821f2a2c83aceb02b1634f40b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 21:50:23 -0400 Subject: [PATCH 06/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Preview=20Artifact?= =?UTF-8?q?=20and=20Branch=20Responses=20and=20Rehydrate=20Subagent=20Prom?= =?UTF-8?q?pts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artifact edits and parallel-response branches merge the message the server returns into the conversation cache, so both requests now ask for previews and their routes honor it. The subagent panel reads the prompt from the stored arguments once they load, and the part endpoint no longer caps identifier length, so any previewed call can be fetched. --- .../__tests__/messages-content-edit.spec.js | 1 + .../routes/__tests__/messages-delete.spec.js | 1 + .../__tests__/messages-feedback.spec.js | 1 + .../messages-get-real-validation.spec.js | 1 + .../routes/__tests__/messages-get.spec.js | 1 + .../__tests__/messages-retention.spec.js | 1 + .../messages-subagent-thread.spec.js | 1 + .../__tests__/messages-tool-previews.spec.js | 15 +++++++++++ .../routes/__tests__/messages-update.spec.js | 1 + api/server/routes/messages.js | 15 ++++++----- .../Messages/Content/Parts/SubagentCall.tsx | 20 ++------------- .../Subagents/SubagentThreadPanel.test.tsx | 6 ++++- .../Chat/Subagents/SubagentThreadPanel.tsx | 21 +++++++++++++--- .../src/components/Chat/Subagents/prompt.ts | 25 +++++++++++++++++++ packages/api/src/messages/parts.spec.ts | 22 +++++++++++++++- packages/api/src/messages/parts.ts | 3 +-- packages/api/src/messages/previews.spec.ts | 19 ++++++++++++++ packages/api/src/messages/previews.ts | 14 +++++++++++ packages/data-provider/src/data-service.ts | 4 +-- 19 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 client/src/components/Chat/Subagents/prompt.ts diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js index 8f2987b2af2..16bc0750224 100644 --- a/api/server/routes/__tests__/messages-content-edit.spec.js +++ b/api/server/routes/__tests__/messages-content-edit.spec.js @@ -10,6 +10,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, unescapeLaTeX: jest.fn((value) => value), countTokens: jest.fn().mockResolvedValue(2), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), diff --git a/api/server/routes/__tests__/messages-delete.spec.js b/api/server/routes/__tests__/messages-delete.spec.js index 04e22a6698a..6dfe494d65b 100644 --- a/api/server/routes/__tests__/messages-delete.spec.js +++ b/api/server/routes/__tests__/messages-delete.spec.js @@ -12,6 +12,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), createContentFilter: jest.fn(() => (req, res, next) => next()), inspectContent: jest.fn(() => null), diff --git a/api/server/routes/__tests__/messages-feedback.spec.js b/api/server/routes/__tests__/messages-feedback.spec.js index 79d67c2f8f3..25064b188d8 100644 --- a/api/server/routes/__tests__/messages-feedback.spec.js +++ b/api/server/routes/__tests__/messages-feedback.spec.js @@ -9,6 +9,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, unescapeLaTeX: jest.fn((value) => value), countTokens: jest.fn().mockResolvedValue(10), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), diff --git a/api/server/routes/__tests__/messages-get-real-validation.spec.js b/api/server/routes/__tests__/messages-get-real-validation.spec.js index a8a7e922ff0..fc2e02a21a5 100644 --- a/api/server/routes/__tests__/messages-get-real-validation.spec.js +++ b/api/server/routes/__tests__/messages-get-real-validation.spec.js @@ -24,6 +24,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), createContentFilter: jest.fn(() => (req, res, next) => next()), inspectContent: jest.fn(() => null), diff --git a/api/server/routes/__tests__/messages-get.spec.js b/api/server/routes/__tests__/messages-get.spec.js index 190598163d1..401b6a049f2 100644 --- a/api/server/routes/__tests__/messages-get.spec.js +++ b/api/server/routes/__tests__/messages-get.spec.js @@ -54,6 +54,7 @@ jest.mock('@librechat/api', () => { prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, /** The real helper, without loading the rest of the package this suite mocks around. */ withoutTraceRefs: jest.requireActual('../../../../packages/api/src/langfuse/trace.ts') .withoutTraceRefs, diff --git a/api/server/routes/__tests__/messages-retention.spec.js b/api/server/routes/__tests__/messages-retention.spec.js index 26740b6f1a9..6aef3dbca93 100644 --- a/api/server/routes/__tests__/messages-retention.spec.js +++ b/api/server/routes/__tests__/messages-retention.spec.js @@ -10,6 +10,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), stripPrivateMessageFields: jest.requireActual( '../../../../packages/api/src/protection/private/view', diff --git a/api/server/routes/__tests__/messages-subagent-thread.spec.js b/api/server/routes/__tests__/messages-subagent-thread.spec.js index a76da38260f..432eed0d7e1 100644 --- a/api/server/routes/__tests__/messages-subagent-thread.spec.js +++ b/api/server/routes/__tests__/messages-subagent-thread.spec.js @@ -9,6 +9,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, withoutTraceRefs: jest.fn((message) => message), createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), createContentFilter: jest.fn(() => (_req, _res, next) => next()), diff --git a/api/server/routes/__tests__/messages-tool-previews.spec.js b/api/server/routes/__tests__/messages-tool-previews.spec.js index a94c98794d3..64619b8b9c8 100644 --- a/api/server/routes/__tests__/messages-tool-previews.spec.js +++ b/api/server/routes/__tests__/messages-tool-previews.spec.js @@ -359,6 +359,21 @@ describe('tool-call previews on the message routes', () => { await expectStoredContentIntact(); }); + it('returns a branched message as a preview to a client that asks, keeping storage full', async () => { + const response = await request(app) + .post('/api/messages/branch?toolPreviews=1') + .send({ messageId: responseId, agentId: 'agent_a' }); + expect(response.status).toBe(201); + expect(response.body.content[1].tool_call.outputTruncated).toBe(true); + expect(response.body.content[2].tool_call.subagentContentOmitted).toBe(true); + const [branched] = await mockDb.methods.getMessages({ + messageId: response.body.messageId, + user: OWNER, + }); + expect(branched.content[1].tool_call.output).toBe(fullOutput); + expect(branched.content[2].tool_call.subagent_content).toEqual(transcript); + }); + it('forking copies the stored content, not the preview', async () => { await loadPreviews(); const { forkConversation } = require('~/server/utils/import/fork'); diff --git a/api/server/routes/__tests__/messages-update.spec.js b/api/server/routes/__tests__/messages-update.spec.js index 383527bf497..8ff14345bf3 100644 --- a/api/server/routes/__tests__/messages-update.spec.js +++ b/api/server/routes/__tests__/messages-update.spec.js @@ -10,6 +10,7 @@ jest.mock('@librechat/api', () => ({ prepareToolCallPreviews: jest.fn(() => (messages) => Promise.resolve(messages)), createToolCallPartHandler: jest.fn(() => (_req, res) => res.status(404).end()), rejectToolCallPreviewWrites: (_req, _res, next) => next(), + withMessageToolCallPreviews: (_req, message) => message, createPrivateTextView: jest.fn(() => (_req, _res, next) => next()), stripPrivateMessageFields: jest.requireActual( '../../../../packages/api/src/protection/private/view', diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 9515e3aa2c3..89019d3d9f8 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -33,6 +33,7 @@ const { prepareToolCallPreviews, createToolCallPartHandler, rejectToolCallPreviewWrites, + withMessageToolCallPreviews, } = require('@librechat/api'); const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore'); const { getAppConfig } = require('~/server/services/Config'); @@ -412,7 +413,7 @@ router.post('/branch', configMiddleware, async (req, res) => { conversationId: sourceMessage.conversationId, }); - res.status(201).json(toClientMessage(savedMessage)); + res.status(201).json(withMessageToolCallPreviews(req, toClientMessage(savedMessage))); } catch (error) { if (isContentFilterError(error)) { return res.status(error.statusCode).json(error.body); @@ -517,11 +518,13 @@ router.post('/artifact/:messageId', configMiddleware, async (req, res) => { conversationId: message.conversationId, }); - res.status(200).json({ - conversationId: savedMessage.conversationId, - content: savedMessage.content, - text: savedMessage.text, - }); + res.status(200).json( + withMessageToolCallPreviews(req, { + conversationId: savedMessage.conversationId, + content: savedMessage.content, + text: savedMessage.text, + }), + ); } catch (error) { if (isContentFilterError(error)) { return res.status(error.statusCode).json(error.body); diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 66e8bd7644c..58d8f1c9020 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -18,6 +18,7 @@ import { import { adaptLivePersistedActivity } from '~/components/Chat/Subagents/adapters'; import { resolveSubagentAgentId } from '~/components/Chat/Subagents/identity'; import { useOpenSubagentPanel } from '~/components/Chat/Subagents/surface'; +import { getSubagentPrompt } from '~/components/Chat/Subagents/prompt'; import { MessageContext } from '~/Providers/MessageContext'; import { useShareContext } from '~/Providers/ShareContext'; import MessageIcon from '~/components/Share/MessageIcon'; @@ -265,7 +266,7 @@ export default function SubagentCall({ shouldThrottleTicker, ); - const prompt = typeof args === 'string' ? tryPrompt(args) : extractPrompt(args); + const prompt = getSubagentPrompt(args); /** Base verb-only label ("Running agent" / "Ran agent"). The agent name * is rendered separately as a muted sub-label so "agent" stays a @@ -493,23 +494,6 @@ function extractSubagentType(args: SubagentCallProps['args']): string { return a?.subagent_type ?? 'agent'; } -function extractPrompt(args: Record | undefined): string | undefined { - if (!args) return undefined; - for (const key of ['prompt', 'description', 'task', 'instructions']) { - const value = args[key]; - if (typeof value === 'string' && value.trim().length > 0) return value; - } - return undefined; -} - -function tryPrompt(args: string): string | undefined { - try { - return extractPrompt(JSON.parse(args) as Record); - } catch { - return undefined; - } -} - /** Stable key for a ticker line — helps React reuse the DOM node across * in-place updates to the same live `writing` / `reasoning` line, and * gives tool-call lines a stable identity by tool name. */ diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 289dc55a510..4fff1256c59 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -1560,7 +1560,7 @@ describe('SubagentThreadPanel', () => { tool_call: { id: 'foreground-call', name: 'subagent', - args: '{"prompt":"Review this change."}', + args: '{"prompt":"Review this change, including the migration and its rollback."}', output: 'Full final answer.', subagent_content: [{ type: 'text', text: 'Stored review transcript.' }], }, @@ -1576,6 +1576,10 @@ describe('SubagentThreadPanel', () => { expect(screen.getByText('Stored review transcript.')).toBeInTheDocument(); expect(screen.getByTestId('subagent-conversation')).toHaveAttribute('data-state', 'ready'); + expect( + screen.getByText('Review this change, including the migration and its rollback.'), + ).toBeInTheDocument(); + expect(screen.queryByText('Review this change.')).not.toBeInTheDocument(); }); it('reports a failed load with a retry instead of rendering the preview', () => { diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx index 6e95f81b363..86e1408d4c3 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.tsx @@ -57,6 +57,7 @@ import { resolveSubagentAgentId } from './identity'; import { useAgentsMapContext } from '~/Providers'; import { isLiveSubagentStatus } from './status'; import { cn, renderAgentAvatar } from '~/utils'; +import { getSubagentPrompt } from './prompt'; import { useChatSurface } from './surface'; const EVENT_TASK_PAGE_SIZE = 3; @@ -795,6 +796,9 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu ); const storedToolCall = awaitsStoredPart ? storedPart.data?.tool_call : undefined; const persistedContent = storedToolCall?.subagent_content ?? selection.persistedContent; + const prompt = + (storedToolCall == null ? undefined : getSubagentPrompt(storedToolCall.args)) ?? + selection.prompt; let legacyOutput = selection.legacyOutput; if (awaitsStoredPart && legacyOutput != null) { legacyOutput = storedToolCall?.output; @@ -804,7 +808,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu () => adaptLivePersistedActivity({ title: foregroundTitle, - prompt: selection.prompt, + prompt, progress, persistedContent, isDetached: selection.durable != null, @@ -815,7 +819,15 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu isSubmitting: selection.durable == null ? selection.isSubmitting : detachedLiveSubmitting, runStepStatus: selection.durable == null ? selection.runStepStatus : undefined, }), - [detachedLiveSubmitting, foregroundTitle, legacyOutput, persistedContent, progress, selection], + [ + detachedLiveSubmitting, + foregroundTitle, + legacyOutput, + persistedContent, + progress, + prompt, + selection, + ], ); const activity = useMemo(() => { if (selection.durable == null) return liveActivity; @@ -945,7 +957,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu selection.event == null ? ('parent_continuation' as const) : ('external_event' as const), - summary: selection.prompt ?? activity.prompt ?? '', + summary: prompt ?? activity.prompt ?? '', ...(selectedTaskCreatedAt == null ? {} : { createdAt: selectedTaskCreatedAt }), }, activity, @@ -972,7 +984,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu trigger: { kind: selection.event == null ? ('parent_dispatch' as const) : ('external_event' as const), - summary: selection.prompt ?? activity.prompt ?? '', + summary: prompt ?? activity.prompt ?? '', }, activity, }, @@ -984,6 +996,7 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu movingWindowTurns, olderTurns, postRebaseTurns, + prompt, rebaseTurns, retainedTurnsValid, threadId, diff --git a/client/src/components/Chat/Subagents/prompt.ts b/client/src/components/Chat/Subagents/prompt.ts new file mode 100644 index 00000000000..8a8f716e365 --- /dev/null +++ b/client/src/components/Chat/Subagents/prompt.ts @@ -0,0 +1,25 @@ +const PROMPT_KEYS = ['prompt', 'description', 'task', 'instructions'] as const; + +/** The task a subagent call was given, read from its arguments (JSON text or parsed). */ +export function getSubagentPrompt( + args: string | Record | null | undefined, +): string | undefined { + let parsed: unknown = args; + if (typeof args === 'string') { + try { + parsed = JSON.parse(args); + } catch { + return undefined; + } + } + if (parsed == null || typeof parsed !== 'object') { + return undefined; + } + for (const key of PROMPT_KEYS) { + const value = (parsed as Record)[key]; + if (typeof value === 'string' && value.trim().length > 0) { + return value; + } + } + return undefined; +} diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index bd19c2f9909..b040467d0c2 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -242,6 +242,27 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => expect((await request(app).get(partUrl(0, 'call_dup'))).status).toBe(404); }); + it('serves a call whose provider id is unusually long', async () => { + const longId = `call_${'x'.repeat(2_000)}`; + await seed(OWNER, { + content: [ + { + type: ContentTypes.TOOL_CALL, + tool_call: { + id: longId, + type: 'tool_call', + name: 'bash_tool', + output: 'done', + args: '{}', + }, + }, + ], + }); + const response = await request(app).get(partUrl(0, longId)); + expect(response.status).toBe(200); + expect(response.body.tool_call.id).toBe(longId); + }); + it('answers not found for a missing part, a non-tool part, or an unknown id', async () => { await seed(); expect((await request(app).get(partUrl(9))).status).toBe(404); @@ -277,7 +298,6 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => expect((await request(strict).get(partUrl('-1'))).status).toBe(400); expect((await request(strict).get(partUrl('1.5'))).status).toBe(400); expect((await request(strict).get(partUrl('99999999999999999999'))).status).toBe(400); - expect((await request(strict).get(partUrl(1, 'x'.repeat(600)))).status).toBe(400); expect(getMessages).not.toHaveBeenCalled(); }); diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index 417a5529145..4aad087fae8 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -49,7 +49,6 @@ interface StoredToolCallPart { tool_call?: FullToolCall; } -const MAX_TOOL_CALL_ID_LENGTH = 512; const PART_INDEX_PATTERN = /^\d+$/; function getAgentToolCall(part: unknown): FullToolCall | undefined { @@ -163,7 +162,7 @@ function parseIdentifier(value: unknown): string | undefined | null { if (value == null) { return undefined; } - if (typeof value !== 'string' || value === '' || value.length > MAX_TOOL_CALL_ID_LENGTH) { + if (typeof value !== 'string' || value === '') { return null; } return value; diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index 31a968b6c19..e821804cd1e 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -10,6 +10,7 @@ import { previewMessagesToolCalls, rejectToolCallPreviewWrites, withToolCallPreviews, + withMessageToolCallPreviews, TOOL_CALL_PREVIEW_ELISION, } from './previews'; @@ -446,6 +447,24 @@ describe('withToolCallPreviews', () => { }); }); +describe('withMessageToolCallPreviews', () => { + it('previews one returned message for a client that asked, and only then', () => { + const message = { + conversationId: 'c1', + text: '', + content: [toolPart({ output: 'o'.repeat(10_000) })], + }; + const config = { toolCallPreviews: toolCallPreviewsConfigSchema.parse({}) }; + const previewed = withMessageToolCallPreviews( + { query: { toolPreviews: '1' }, config }, + message, + ); + expect(previewed).toMatchObject({ conversationId: 'c1', text: '' }); + expect(previewed.content[0]).toMatchObject({ tool_call: { outputTruncated: true } }); + expect(withMessageToolCallPreviews({ query: {}, config }, message)).toBe(message); + }); +}); + describe('rejectToolCallPreviewWrites', () => { const run = (content: unknown) => { const json = jest.fn(); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index e62dda89fef..0bb7c20b2d3 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -542,6 +542,20 @@ export function withToolCallPreviews( + req: PreviewableResultRequest, + message: T, +): T { + if (message == null || !Array.isArray(message.content)) { + return message; + } + return withToolCallPreviews(req, { messages: [message] }).messages[0]; +} + interface MessageWriteRequest { body?: { content?: unknown } | null; } diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 6fc8b077ff0..3fb895a94a2 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -1145,13 +1145,13 @@ export const editArtifact = async ({ messageId, ...params }: m.TEditArtifactRequest): Promise => { - return request.post(endpoints.messagesArtifacts(messageId), params); + return request.post(withToolCallPreviews(endpoints.messagesArtifacts(messageId)), params); }; export const branchMessage = async ( payload: m.TBranchMessageRequest, ): Promise => { - return request.post(endpoints.messagesBranch(), payload); + return request.post(withToolCallPreviews(endpoints.messagesBranch()), payload); }; export interface OwnerMessageText { From 318f6eeed41fde5dbc7e81b1b826c35c31763a69 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 22:03:41 -0400 Subject: [PATCH 07/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Preview=20Only=20To?= =?UTF-8?q?ol=20Calls=20That=20Can=20Be=20Fetched=20Back=20by=20Identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call without an id has nothing but its position to name it, and the position can shift on the client, so it is left whole instead of being previewed. The part endpoint now requires a tool-call id, so an indexed lookup always confirms the call it returns. --- .../__tests__/messages-tool-previews.spec.js | 7 +++++-- packages/api/src/messages/parts.spec.ts | 16 +++++++++++----- packages/api/src/messages/parts.ts | 4 +++- packages/api/src/messages/previews.spec.ts | 7 +++++++ packages/api/src/messages/previews.ts | 7 ++++++- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/api/server/routes/__tests__/messages-tool-previews.spec.js b/api/server/routes/__tests__/messages-tool-previews.spec.js index 64619b8b9c8..c99359e7b6d 100644 --- a/api/server/routes/__tests__/messages-tool-previews.spec.js +++ b/api/server/routes/__tests__/messages-tool-previews.spec.js @@ -252,7 +252,8 @@ describe('tool-call previews on the message routes', () => { GenerationJobManager.getJob.mockResolvedValue(null); }); - const partUrl = () => `/api/messages/${liveConversationId}/live-response/parts/1`; + const partUrl = () => + `/api/messages/${liveConversationId}/live-response/parts/1?toolCallId=call_bash`; it('serves the part to the job owner, as the conversation read does', async () => { GenerationJobManager.getJob.mockResolvedValue({ @@ -311,7 +312,9 @@ describe('tool-call previews on the message routes', () => { { userId: OWNER }, { messageId: 'child-msg', conversationId: childId, content: assistantContent() }, ); - const response = await request(app).get(`/api/messages/${childId}/child-msg/parts/1`); + const response = await request(app).get( + `/api/messages/${childId}/child-msg/parts/1?toolCallId=call_bash`, + ); expect(response.status).toBe(404); }); diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index b040467d0c2..ad54e448b60 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -130,7 +130,12 @@ const mount = (route: ReturnType) => { return server; }; -const partUrl = (index: number | string, toolCallId?: string, convo = conversationId) => +/** `toolCallId` defaults to the seeded bash call; `null` leaves the parameter out. */ +const partUrl = ( + index: number | string, + toolCallId: string | null = 'call_bash', + convo = conversationId, +) => `/api/messages/${convo}/${messageId}/parts/${index}${ toolCallId == null ? '' : `?toolCallId=${encodeURIComponent(toolCallId)}` }`; @@ -265,9 +270,9 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => it('answers not found for a missing part, a non-tool part, or an unknown id', async () => { await seed(); - expect((await request(app).get(partUrl(9))).status).toBe(404); - expect((await request(app).get(partUrl(250_000))).status).toBe(404); - expect((await request(app).get(partUrl(0))).status).toBe(404); + expect((await request(app).get(partUrl(9, 'call_missing'))).status).toBe(404); + expect((await request(app).get(partUrl(250_000, 'call_missing'))).status).toBe(404); + expect((await request(app).get(partUrl(0, 'call_missing'))).status).toBe(404); expect((await request(app).get(partUrl(1, 'call_missing'))).status).toBe(404); }); @@ -280,7 +285,7 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => it('never returns a message through a different conversation id', async () => { await seed(); - const response = await request(app).get(partUrl(1, undefined, otherConversationId)); + const response = await request(app).get(partUrl(1, 'call_bash', otherConversationId)); expect(response.status).toBe(404); }); @@ -295,6 +300,7 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => it('rejects malformed coordinates before reading', async () => { const getMessages = jest.fn(); const strict = mount(handler(getMessages)); + expect((await request(strict).get(partUrl(1, null))).status).toBe(400); expect((await request(strict).get(partUrl('-1'))).status).toBe(400); expect((await request(strict).get(partUrl('1.5'))).status).toBe(400); expect((await request(strict).get(partUrl('99999999999999999999'))).status).toBe(400); diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index 4aad087fae8..db75521966d 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -189,7 +189,9 @@ export function createToolCallPartHandler( res.status(404).json({ error: 'Tool call not found' }); return; } - if (partIndex == null || toolCallId === null || stepId === null || agentId === null) { + /** Previews are only produced for calls with an id, so a request without one has no part + * it could stably name: an index alone may point at another call once content shifted. */ + if (partIndex == null || toolCallId == null || stepId === null || agentId === null) { res.status(400).json({ error: 'Invalid tool call part' }); return; } diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index e821804cd1e..adeaabbe6b4 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -186,6 +186,13 @@ describe('previewToolCall', () => { expect(sandbox.output?.endsWith(trailer)).toBe(false); }); + it('leaves a call without an id whole, since it could not be fetched back by identity', () => { + const anonymous = toolPart({ id: '', output: 'o'.repeat(5_000) }).tool_call; + expect(previewToolCall(anonymous, limits)).toBe(anonymous); + const missing = { ...toolPart({ output: 'o'.repeat(5_000) }).tool_call, id: undefined }; + expect(previewToolCall(missing, limits)).toBe(missing); + }); + it('returns the same object when nothing exceeds the bounds', () => { const toolCall = toolPart({ output: 'ok', args: '{}' }).tool_call; expect(previewToolCall(toolCall, limits)).toBe(toolCall); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index 0bb7c20b2d3..962e538e8ed 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -56,6 +56,7 @@ type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string /** The stored fields this module reads; everything else on the tool call passes through. */ interface StoredToolCall extends ToolCallPreviewMarkers { type?: string; + id?: unknown; name?: string; executor?: string; progress?: unknown; @@ -92,6 +93,10 @@ const isAgentToolCall = (toolCall: StoredToolCall): boolean => const hasOutput = (toolCall: StoredToolCall): boolean => typeof toolCall.output === 'string' && toolCall.output.length > 0; +/** Only a call with an id can be fetched back by identity, so only such calls are previewed. */ +const hasIdentity = (toolCall: StoredToolCall): boolean => + typeof toolCall.id === 'string' && toolCall.id !== ''; + /** * A call whose content no card still acts on: it has output, or its run step closed or reached * full progress, and it is not waiting on an approval. A subagent can finish with no final text, @@ -335,7 +340,7 @@ export function previewToolCall( toolCall: T, limits: ToolCallPreviewLimits, ): T { - if (!isAgentToolCall(toolCall) || !isSettled(toolCall)) { + if (!isAgentToolCall(toolCall) || !isSettled(toolCall) || !hasIdentity(toolCall)) { return toolCall; } if (typeof toolCall.name === 'string' && FULL_CONTENT_TOOLS.has(toolCall.name)) { From 68b1f1a9eae73c4eb04e11087a78d6df128adbfc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 22:18:43 -0400 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Fetch=20Parts=20by?= =?UTF-8?q?=20Exact=20Identity=20and=20Preview=20Only=20Unique=20Ones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part endpoint now finds a call by its exact identity (provider id, run-step id, agent) instead of trusting the client's index, and the preview pass only shortens calls whose identity is unique in their message, so every preview names exactly one stored part. Legacy web-search failures that carry their error phrase mid-output go whole, since the collapsed card reads that phrase anywhere. --- .../__tests__/messages-tool-previews.spec.js | 6 +-- packages/api/src/messages/parts.spec.ts | 35 +++++++++++---- packages/api/src/messages/parts.ts | 33 +++++--------- packages/api/src/messages/previews.spec.ts | 23 ++++++++++ packages/api/src/messages/previews.ts | 43 +++++++++++++++++++ 5 files changed, 106 insertions(+), 34 deletions(-) diff --git a/api/server/routes/__tests__/messages-tool-previews.spec.js b/api/server/routes/__tests__/messages-tool-previews.spec.js index c99359e7b6d..4d58bb98830 100644 --- a/api/server/routes/__tests__/messages-tool-previews.spec.js +++ b/api/server/routes/__tests__/messages-tool-previews.spec.js @@ -220,7 +220,7 @@ describe('tool-call previews on the message routes', () => { }); it('serves the full part to its owner and nobody else', async () => { - const url = `/api/messages/${conversationId}/${responseId}/parts/1?toolCallId=call_bash`; + const url = `/api/messages/${conversationId}/${responseId}/parts/1?toolCallId=call_bash&agentId=agent_a`; const owner = await request(app).get(url); expect(owner.status).toBe(200); expect(owner.body.tool_call.output).toBe(fullOutput); @@ -253,7 +253,7 @@ describe('tool-call previews on the message routes', () => { }); const partUrl = () => - `/api/messages/${liveConversationId}/live-response/parts/1?toolCallId=call_bash`; + `/api/messages/${liveConversationId}/live-response/parts/1?toolCallId=call_bash&agentId=agent_a`; it('serves the part to the job owner, as the conversation read does', async () => { GenerationJobManager.getJob.mockResolvedValue({ @@ -313,7 +313,7 @@ describe('tool-call previews on the message routes', () => { { messageId: 'child-msg', conversationId: childId, content: assistantContent() }, ); const response = await request(app).get( - `/api/messages/${childId}/child-msg/parts/1?toolCallId=call_bash`, + `/api/messages/${childId}/child-msg/parts/1?toolCallId=call_bash&agentId=agent_a`, ); expect(response.status).toBe(404); }); diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index ad54e448b60..93f7794430d 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -234,17 +234,34 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => repeated('agent_b', 'step_b', 'from agent b'), ], }); - const byStep = await request(app).get(`${partUrl(0, 'call_dup')}&stepId=step_b`); - expect(byStep.status).toBe(200); - expect(byStep.body).toMatchObject({ partIndex: 2, tool_call: { output: 'from agent b' } }); - - const byAgent = await request(app).get(`${partUrl(0, 'call_dup')}&agentId=agent_a`); - expect(byAgent.body).toMatchObject({ partIndex: 1, tool_call: { output: 'from agent a' } }); + const identity = (agentId: string, stepId: string) => + `${partUrl(0, 'call_dup')}&stepId=${stepId}&agentId=${agentId}`; + const agentB = await request(app).get(identity('agent_b', 'step_b')); + expect(agentB.status).toBe(200); + expect(agentB.body).toMatchObject({ partIndex: 2, tool_call: { output: 'from agent b' } }); + + const agentA = await request(app).get(identity('agent_a', 'step_a')); + expect(agentA.body).toMatchObject({ partIndex: 1, tool_call: { output: 'from agent a' } }); + + /** An index pointing at the other agent's call does not win over the identity. */ + const shifted = await request(app).get( + `${partUrl(1, 'call_dup')}&stepId=step_b&agentId=agent_b`, + ); + expect(shifted.body).toMatchObject({ partIndex: 2 }); - const indexedMismatch = await request(app).get(`${partUrl(1, 'call_dup')}&stepId=step_b`); - expect(indexedMismatch.body).toMatchObject({ partIndex: 2 }); + /** Without the step and agent, both calls carry the requested identity loosely; neither does + * exactly, so nothing is returned. */ + expect((await request(app).get(partUrl(1, 'call_dup'))).status).toBe(404); + }); - expect((await request(app).get(partUrl(0, 'call_dup'))).status).toBe(404); + it('refuses an identity that repeats exactly, even when the index points at one occurrence', async () => { + const twin = (output: string) => ({ + type: ContentTypes.TOOL_CALL, + tool_call: { id: 'call_twin', type: 'tool_call', name: 'read_file', output, args: '{}' }, + }); + await seed(OWNER, { content: [twin('first'), twin('second')] }); + expect((await request(app).get(partUrl(0, 'call_twin'))).status).toBe(404); + expect((await request(app).get(partUrl(1, 'call_twin'))).status).toBe(404); }); it('serves a call whose provider id is unusually long', async () => { diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index db75521966d..89fb1ce33de 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -62,39 +62,28 @@ function getAgentToolCall(part: unknown): FullToolCall | undefined { return toolCall.type == null || toolCall.type === 'tool_call' ? toolCall : undefined; } -/** - * Finds the part at its index, or by tool-call id when the client's copy of the content no - * longer lines up with storage (a client-only card was inserted or removed). An id that matches - * a different part than the index points at never wins over the indexed part. - */ +/** Exact match: an absent step or agent matches only a call that has none, as the preview pass + * assumed when it checked the identity was unique. */ function matchesIdentity(part: unknown, toolCall: FullToolCall, identity: PartIdentity): boolean { - if (identity.toolCallId != null && toolCall.id !== identity.toolCallId) { + if (toolCall.id !== identity.toolCallId) { return false; } - if (identity.stepId != null && toolCall.stepId !== identity.stepId) { + if ((toolCall.stepId ?? undefined) !== (identity.stepId ?? undefined)) { return false; } - return identity.agentId == null || (part as StoredToolCallPart).agentId === identity.agentId; + return ((part as StoredToolCallPart).agentId ?? undefined) === (identity.agentId ?? undefined); } /** - * Finds the part at its index when it carries the requested identity. Otherwise, when the - * client's copy no longer lines up with storage (a client-only card was inserted or removed), - * scans by identity and accepts only a single match: provider ids can repeat within a response, - * so an ambiguous match resolves to not found rather than to another call's content. + * Finds the one part carrying the requested identity. The client's index is not trusted for + * this: its copy of the content can be shifted by client-only cards. The preview pass only + * shortens calls whose identity is unique in their message, so a request for a preview always + * names exactly one part; a repeated identity resolves to not found, never to another call. */ function locatePart( content: unknown[], - partIndex: number, identity: PartIdentity, ): { partIndex: number; toolCall: FullToolCall } | undefined { - const indexed = getAgentToolCall(content[partIndex]); - if (indexed != null && matchesIdentity(content[partIndex], indexed, identity)) { - return { partIndex, toolCall: indexed }; - } - if (identity.toolCallId == null) { - return undefined; - } let found: { partIndex: number; toolCall: FullToolCall } | undefined; for (let i = 0; i < content.length; i++) { const toolCall = getAgentToolCall(content[i]); @@ -114,7 +103,7 @@ export async function readToolCallPart( deps: ToolCallPartDeps, input: ToolCallPartInput, ): Promise { - const { user, conversationId, messageId, partIndex, toolCallId, stepId, agentId } = input; + const { user, conversationId, messageId, toolCallId, stepId, agentId } = input; const messages = await deps.getMessages( { user, conversationId, messageId }, CLIENT_MESSAGE_SELECT, @@ -123,7 +112,7 @@ export async function readToolCallPart( if (!Array.isArray(content)) { return { ok: false, error: { code: 'message_not_found' } }; } - const located = locatePart(content, partIndex, { toolCallId, stepId, agentId }); + const located = locatePart(content, { toolCallId, stepId, agentId }); if (located == null) { return { ok: false, error: { code: 'part_not_found' } }; } diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index adeaabbe6b4..80b12849c99 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -25,6 +25,7 @@ type TestToolCall = { executor?: string; progress?: number; runStepStatus?: string; + stepId?: string; approval?: unknown; subagent_content?: unknown[]; outputTruncated?: true; @@ -293,6 +294,14 @@ describe('previewToolCall', () => { expect(otherPreview.output?.length).toBeLessThanOrEqual(limits.outputChars); }); + it('keeps a legacy web-search error whole, since its card reads the phrase anywhere', () => { + const output = `${'result '.repeat(500)}Error processing request${' tail'.repeat(500)}`; + const search = toolPart({ name: 'web_search', output }).tool_call; + expect(previewToolCall(search, limits).output).toBe(output); + const plain = toolPart({ name: 'web_search', output: 'result '.repeat(2_000) }).tool_call; + expect(previewToolCall(plain, limits).outputTruncated).toBe(true); + }); + it('leaves the question-and-answer record and legacy Assistants calls whole', () => { const ask = toolPart({ name: 'ask_user_question', output: 'a'.repeat(5_000) }).tool_call; expect(previewToolCall(ask, limits)).toBe(ask); @@ -361,6 +370,20 @@ describe('previewMessagesToolCalls', () => { expect(bigPart.tool_call.output).toHaveLength(10_000); }); + it('previews a call only when its identity is unique in the message', () => { + const repeated = (agentId: string) => ({ + ...toolPart({ id: 'call_dup', stepId: 'step_1', output: 'o'.repeat(10_000) }), + agentId, + }); + const content = [repeated('a'), repeated('a'), repeated('a'), repeated('b')]; + const [message] = previewMessagesToolCalls([{ content }], limits); + const parts = message.content as Array<{ tool_call: TestToolCall }>; + expect(parts[0]).toBe(content[0]); + expect(parts[1]).toBe(content[1]); + expect(parts[2]).toBe(content[2]); + expect(parts[3].tool_call.outputTruncated).toBe(true); + }); + it('returns the same array when nothing needs shortening', () => { const messages: TestMessage[] = [{ content: [toolPart({ output: 'ok' })] }]; expect(previewMessagesToolCalls(messages, limits)).toBe(messages); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index 962e538e8ed..ceccc3e09b9 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -1,5 +1,6 @@ import { logger } from '@librechat/data-schemas'; import { + Tools, Constants, ContentTypes, TOOL_CALL_PREVIEWS_PARAM, @@ -51,12 +52,19 @@ const PARSED_OUTPUT_TOOLS: ReadonlySet = new Set([ Constants.CHECK_BACKGROUND_TASK as string, ]); +/** + * Older web-search failures carry only this phrase, anywhere in the output, and the collapsed + * card reads it to label the search as not completed; such output goes whole. + */ +const LEGACY_SEARCH_ERROR = /error processing/i; + type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; /** The stored fields this module reads; everything else on the tool call passes through. */ interface StoredToolCall extends ToolCallPreviewMarkers { type?: string; id?: unknown; + stepId?: unknown; name?: string; executor?: string; progress?: unknown; @@ -69,6 +77,7 @@ interface StoredToolCall extends ToolCallPreviewMarkers { interface StoredToolCallPart { type?: string; + agentId?: unknown; tool_call?: StoredToolCall; } @@ -97,6 +106,22 @@ const hasOutput = (toolCall: StoredToolCall): boolean => const hasIdentity = (toolCall: StoredToolCall): boolean => typeof toolCall.id === 'string' && toolCall.id !== ''; +/** + * The identity the client sends to fetch a part back: provider call id, host run-step id and + * the part's agent, matched exactly by the part endpoint. Provider ids can repeat within a + * response, so a call is previewed only when this identity is unique in its message; otherwise + * no request could name it without also naming another call. + */ +function toolCallIdentity(part: StoredToolCallPart & { tool_call: StoredToolCall }): string | null { + const { id, stepId } = part.tool_call; + if (typeof id !== 'string' || id === '') { + return null; + } + const step = typeof stepId === 'string' ? stepId : ''; + const agent = typeof part.agentId === 'string' ? part.agentId : ''; + return `${id}\u0000${step}\u0000${agent}`; +} + /** * A call whose content no card still acts on: it has output, or its run step closed or reached * full progress, and it is not waiting on an approval. A subagent can finish with no final text, @@ -328,6 +353,9 @@ export function previewToolCallOutput( ) { return output; } + if (toolCall.name === Tools.web_search && LEGACY_SEARCH_ERROR.test(output)) { + return output; + } return previewOutputText(output, maxChars, commandTrailerLength(toolCall, output)); } @@ -401,11 +429,26 @@ export function previewContentToolCalls( limits: ToolCallPreviewLimits, ): unknown[] { let result: unknown[] | undefined; + /** First index of each identity; a repeat sends both calls whole (see `toolCallIdentity`). */ + const firstIndex = new Map(); for (let i = 0; i < content.length; i++) { const part = content[i]; if (!isToolCallPart(part)) { continue; } + const identity = toolCallIdentity(part); + if (identity == null) { + continue; + } + const first = firstIndex.get(identity); + if (first != null) { + if (result != null && first >= 0) { + result[first] = content[first]; + } + firstIndex.set(identity, -1); + continue; + } + firstIndex.set(identity, i); const toolCall = previewToolCallSafely(part.tool_call, limits); if (toolCall === part.tool_call) { continue; From 7e5ff7b1bed78c18f3e73ae7d799e765f2051ff1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 22:30:24 -0400 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Bound=20the=20JSON?= =?UTF-8?q?=20Fit=20Walk=20Before=20Queueing=20and=20Normalize=20Empty=20I?= =?UTF-8?q?dentities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structure check now counts an array's or object's children against the node budget before queueing them, so a very wide value is refused without building a work list its size. An empty run-step or agent id is treated as absent by the part lookup, matching how the preview pass and the client already treat it. --- packages/api/src/messages/parts.spec.ts | 22 ++++++++++++++++++++++ packages/api/src/messages/parts.ts | 8 ++++++-- packages/api/src/messages/previews.spec.ts | 10 ++++++++++ packages/api/src/messages/previews.ts | 12 ++++++++---- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/api/src/messages/parts.spec.ts b/packages/api/src/messages/parts.spec.ts index 93f7794430d..5aff4bf6946 100644 --- a/packages/api/src/messages/parts.spec.ts +++ b/packages/api/src/messages/parts.spec.ts @@ -264,6 +264,28 @@ describe('GET /api/messages/:conversationId/:messageId/parts/:partIndex', () => expect((await request(app).get(partUrl(1, 'call_twin'))).status).toBe(404); }); + it('treats an empty step or agent as absent, so such a preview stays fetchable', async () => { + await seed(OWNER, { + content: [ + { + type: ContentTypes.TOOL_CALL, + agentId: '', + tool_call: { + id: 'call_empty', + stepId: '', + type: 'tool_call', + name: 'bash_tool', + output: 'done', + args: '{}', + }, + }, + ], + }); + const response = await request(app).get(partUrl(0, 'call_empty')); + expect(response.status).toBe(200); + expect(response.body.tool_call.id).toBe('call_empty'); + }); + it('serves a call whose provider id is unusually long', async () => { const longId = `call_${'x'.repeat(2_000)}`; await seed(OWNER, { diff --git a/packages/api/src/messages/parts.ts b/packages/api/src/messages/parts.ts index 89fb1ce33de..0802463116d 100644 --- a/packages/api/src/messages/parts.ts +++ b/packages/api/src/messages/parts.ts @@ -62,16 +62,20 @@ function getAgentToolCall(part: unknown): FullToolCall | undefined { return toolCall.type == null || toolCall.type === 'tool_call' ? toolCall : undefined; } +/** An empty or missing discriminator is the same absent value, on both sides. */ +const discriminator = (value: unknown): string | undefined => + typeof value === 'string' && value !== '' ? value : undefined; + /** Exact match: an absent step or agent matches only a call that has none, as the preview pass * assumed when it checked the identity was unique. */ function matchesIdentity(part: unknown, toolCall: FullToolCall, identity: PartIdentity): boolean { if (toolCall.id !== identity.toolCallId) { return false; } - if ((toolCall.stepId ?? undefined) !== (identity.stepId ?? undefined)) { + if (discriminator(toolCall.stepId) !== discriminator(identity.stepId)) { return false; } - return ((part as StoredToolCallPart).agentId ?? undefined) === (identity.agentId ?? undefined); + return discriminator((part as StoredToolCallPart).agentId) === discriminator(identity.agentId); } /** diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index 80b12849c99..377ae519fe4 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -331,6 +331,16 @@ describe('wide or unusual JSON', () => { expect(preview.output?.length).toBeLessThanOrEqual(limits.outputChars); }); + it('refuses a wide array without queueing its elements', () => { + const output = JSON.stringify(Array.from({ length: 300_000 }, () => 1)); + const push = jest.spyOn(Array.prototype, 'push'); + const preview = previewToolCall(toolPart({ output }).tool_call, limits); + const pushes = push.mock.calls.length; + push.mockRestore(); + expect(pushes).toBeLessThan(1_000); + expect(preview.outputTruncated).toBe(true); + }); + it('keeps a __proto__ key as ordinary data', () => { const args = `{"__proto__":{"mode":"x"},"pad":"${'p'.repeat(4_000)}"}`; const preview = previewToolCallArgs(args, 512); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index ceccc3e09b9..1065822fba3 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -215,9 +215,10 @@ function capJsonStrings(value: JsonValue, maxChars: number): JsonValue { * Whether the JSON could fit `maxChars` once its strings are capped: every value serializes to * at least two characters with its separator, every string to at least `MIN_JSON_STRING_CHARS`, * and property names are never shortened, so a structure with more values, strings or key - * characters than that allows cannot fit at any cap. The walk is iterative, stops as soon as either bound is passed, and treats - * nesting deeper than `MAX_JSON_DEPTH` as unfit, so the cloning and serialization that follow - * only ever run on small, shallow values. + * characters than that allows cannot fit at any cap. The walk is iterative and counts children + * before queueing them, so its work list never outgrows the node budget; it stops as soon as any + * bound is passed and treats nesting deeper than `MAX_JSON_DEPTH` as unfit, so the cloning and + * serialization that follow only ever run on small, shallow values. */ function mayFitJson(value: JsonValue, maxChars: number): boolean { const maxNodes = Math.floor(maxChars / 2); @@ -238,13 +239,16 @@ function mayFitJson(value: JsonValue, maxChars: number): boolean { return false; } } else if (Array.isArray(current)) { + if (nodes + stack.length + current.length > maxNodes) { + return false; + } for (const item of current) { stack.push([item, depth + 1]); } } else if (current != null && typeof current === 'object') { for (const key in current) { keyChars += key.length + 3; - if (keyChars > maxChars) { + if (keyChars > maxChars || nodes + stack.length + 1 > maxNodes) { return false; } stack.push([current[key], depth + 1]); From 9f223b1c44e7efd5bbb6defac63186e239ff56db Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 22:44:11 -0400 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Key=20Fetched=20Par?= =?UTF-8?q?ts=20to=20the=20Stored=20Version=20and=20Hydrate=20Search=20Det?= =?UTF-8?q?ails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each preview now carries `previewRevision`, when its message last changed, and the client keys fetched parts to that plus a fingerprint of the whole preview, so any change to the stored call is fetched again. The web-search details card asks for the stored call when it opens, so its query is never left shortened. --- .../Chat/Messages/Content/WebSearch.tsx | 3 +- .../Content/__tests__/WebSearch.test.tsx | 23 +++++++++ .../Chat/Messages/Content/hydration.tsx | 4 +- packages/api/src/messages/previews.spec.ts | 18 ++++++- packages/api/src/messages/previews.ts | 22 +++++++-- packages/data-provider/src/previews.spec.ts | 47 +++++++++++++++++++ packages/data-provider/src/previews.ts | 29 +++++++++--- packages/data-provider/src/types/agents.ts | 3 ++ 8 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 packages/data-provider/src/previews.spec.ts diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index 1a79a83a37a..78b2cefb54d 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -16,9 +16,9 @@ import type { PartMetadata, AnswerBoxResult, } from 'librechat-data-provider'; +import { useToolExpansion, useToolContentRequest, toolPanelSpacingClassName } from './disclosure'; import { FaviconImage, getCleanDomain } from '~/components/Web/SourceHovercard'; import { useLocalize, useExpandCollapse, useLazyCollapseBody } from '~/hooks'; -import { toolPanelSpacingClassName, useToolExpansion } from './disclosure'; import { collectSources, getUniqueDomainSources } from './sources'; import { StackedFavicons } from '~/components/Web/Sources'; import { isError } from './ToolOutput/OutputRenderer'; @@ -204,6 +204,7 @@ export default function WebSearch({ const sourceCount = allSources.length; const [showDetails, setShowDetails] = useState(false); + useToolContentRequest(showDetails); const [showSourceList, setShowSourceList] = useToolExpansion(sourceCount > 0); const { style: sourceExpandStyle, ref: sourceExpandRef } = useExpandCollapse(showSourceList); const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showSourceList); diff --git a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx index 42d7555d7ac..c82c6164c3c 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/WebSearch.test.tsx @@ -8,6 +8,7 @@ import type { ValidSource, PartMetadata, } from 'librechat-data-provider'; +import { ToolContentRequestContext } from '../disclosure'; import { FailedRevealContext } from '../reveal'; import { SearchContext } from '~/Providers'; import { ROW_GLYPH_SLOT } from '../rows'; @@ -434,6 +435,28 @@ describe('WebSearch', () => { expect(screen.getByText('1 source')).toBeInTheDocument(); }); + it('asks for the stored call when the details card opens', async () => { + const request = jest.fn(); + render( + + + + + + + , + ); + expect(request).not.toHaveBeenCalled(); + fireEvent.focus(screen.getByLabelText('Search details')); + await screen.findByText('shortened quer…'); + expect(request).toHaveBeenCalled(); + }); + it('renders shopping, image, and place verticals in the expanded panel', () => { const attachment = { type: Tools.web_search, diff --git a/client/src/components/Chat/Messages/Content/hydration.tsx b/client/src/components/Chat/Messages/Content/hydration.tsx index 98e5118bb05..9481cc516da 100644 --- a/client/src/components/Chat/Messages/Content/hydration.tsx +++ b/client/src/components/Chat/Messages/Content/hydration.tsx @@ -37,6 +37,7 @@ export function withFullToolCall(part: ToolCallPart, full: FullToolCall): ToolCa argsLength: _argsLength, subagentContentOmitted, subagentContentParts: _subagentContentParts, + previewRevision: _previewRevision, ...toolCall } = part.tool_call as FullToolCall; return { @@ -69,6 +70,7 @@ export function PreviewedToolCallPart({ const [requested, setRequested] = useState(false); const request = useCallback(() => setRequested(true), []); const toolCall = part.tool_call as FullToolCall; + const revision = useMemo(() => getToolCallPreviewRevision(toolCall), [toolCall]); const canFetch = !!conversationId && !!messageId && partIndex != null; const query = useToolCallPartQuery( { @@ -80,7 +82,7 @@ export function PreviewedToolCallPart({ agentId: part.agentId, }, { enabled: requested && canFetch }, - getToolCallPreviewRevision(toolCall), + revision, ); const full = query.data?.tool_call; const effectivePart = useMemo( diff --git a/packages/api/src/messages/previews.spec.ts b/packages/api/src/messages/previews.spec.ts index 377ae519fe4..2a4180357d9 100644 --- a/packages/api/src/messages/previews.spec.ts +++ b/packages/api/src/messages/previews.spec.ts @@ -34,7 +34,7 @@ type TestToolCall = { function?: unknown; }; -type TestMessage = { messageId?: string; content?: unknown[] }; +type TestMessage = { messageId?: string; content?: unknown[]; updatedAt?: Date }; const toolPart = (toolCall: TestToolCall) => ({ type: ContentTypes.TOOL_CALL, @@ -394,6 +394,22 @@ describe('previewMessagesToolCalls', () => { expect(parts[3].tool_call.outputTruncated).toBe(true); }); + it('stamps each preview with when its message last changed', () => { + const updatedAt = new Date('2026-10-05T01:00:00Z'); + const [message] = previewMessagesToolCalls( + [ + { + updatedAt, + content: [toolPart({ output: 'o'.repeat(10_000) }), toolPart({ id: 'b', output: 'ok' })], + }, + ], + limits, + ); + const parts = message.content as Array<{ tool_call: Record }>; + expect(parts[0].tool_call.previewRevision).toBe(String(updatedAt.getTime())); + expect(parts[1].tool_call).not.toHaveProperty('previewRevision'); + }); + it('returns the same array when nothing needs shortening', () => { const messages: TestMessage[] = [{ content: [toolPart({ output: 'ok' })] }]; expect(previewMessagesToolCalls(messages, limits)).toBe(messages); diff --git a/packages/api/src/messages/previews.ts b/packages/api/src/messages/previews.ts index 1065822fba3..7b154f4bc26 100644 --- a/packages/api/src/messages/previews.ts +++ b/packages/api/src/messages/previews.ts @@ -84,6 +84,8 @@ interface StoredToolCallPart { /** A message as read for a client; only `content` is inspected. */ export interface PreviewableMessage { content?: unknown[] | null; + /** Stamped onto each preview as `previewRevision`, so clients key fetched parts to it. */ + updatedAt?: Date | string | number | null; } const isObject = (value: unknown): value is object => value != null && typeof value === 'object'; @@ -371,6 +373,7 @@ export function previewToolCallOutput( export function previewToolCall( toolCall: T, limits: ToolCallPreviewLimits, + revision = '', ): T { if (!isAgentToolCall(toolCall) || !isSettled(toolCall) || !hasIdentity(toolCall)) { return toolCall; @@ -408,6 +411,9 @@ export function previewToolCall( next.subagentContentOmitted = true; next.subagentContentParts = (subagentContent as unknown[]).length; } + if (revision !== '') { + next.previewRevision = revision; + } return next; } @@ -418,9 +424,10 @@ export function previewToolCall( function previewToolCallSafely( toolCall: T, limits: ToolCallPreviewLimits, + revision: string, ): T { try { - return previewToolCall(toolCall, limits); + return previewToolCall(toolCall, limits, revision); } catch (error) { logger.warn('[toolCallPreviews] Sending a tool call in full; its preview failed', error); return toolCall; @@ -431,6 +438,7 @@ function previewToolCallSafely( export function previewContentToolCalls( content: unknown[], limits: ToolCallPreviewLimits, + revision = '', ): unknown[] { let result: unknown[] | undefined; /** First index of each identity; a repeat sends both calls whole (see `toolCallIdentity`). */ @@ -453,7 +461,7 @@ export function previewContentToolCalls( continue; } firstIndex.set(identity, i); - const toolCall = previewToolCallSafely(part.tool_call, limits); + const toolCall = previewToolCallSafely(part.tool_call, limits, revision); if (toolCall === part.tool_call) { continue; } @@ -463,6 +471,14 @@ export function previewContentToolCalls( return result ?? content; } +function messageRevision(message: PreviewableMessage): string { + const { updatedAt } = message; + if (updatedAt instanceof Date) { + return String(updatedAt.getTime()); + } + return updatedAt == null ? '' : String(updatedAt); +} + /** Applies previews across messages, copying only the messages whose content changed. */ export function previewMessagesToolCalls( messages: T[], @@ -474,7 +490,7 @@ export function previewMessagesToolCalls( if (!Array.isArray(message?.content)) { continue; } - const content = previewContentToolCalls(message.content, limits); + const content = previewContentToolCalls(message.content, limits, messageRevision(message)); if (content === message.content) { continue; } diff --git a/packages/data-provider/src/previews.spec.ts b/packages/data-provider/src/previews.spec.ts new file mode 100644 index 00000000000..111db865df4 --- /dev/null +++ b/packages/data-provider/src/previews.spec.ts @@ -0,0 +1,47 @@ +import type { FullToolCall } from './previews'; +import { getToolCallPreviewRevision, hasToolCallPreview } from './previews'; + +const preview = (overrides: Partial = {}): FullToolCall => ({ + id: 'call_1', + name: 'bash_tool', + args: '{"command":"make"}', + output: 'stdout:\nfirst\n…\n[exit code: 0]', + outputTruncated: true, + outputLength: 40_000, + previewRevision: '1700000000000', + ...overrides, +}); + +describe('getToolCallPreviewRevision', () => { + it('is stable for the same preview', () => { + expect(getToolCallPreviewRevision(preview())).toBe(getToolCallPreviewRevision(preview())); + }); + + it('changes when the head changes even with the same length and tail', () => { + const before = getToolCallPreviewRevision(preview()); + const after = getToolCallPreviewRevision( + preview({ output: 'stdout:\nfirsT\n…\n[exit code: 0]' }), + ); + expect(after).not.toBe(before); + }); + + it('changes when the stored message changes, even if the preview looks identical', () => { + expect(getToolCallPreviewRevision(preview({ previewRevision: '1700000000001' }))).not.toBe( + getToolCallPreviewRevision(preview()), + ); + }); + + it('changes with the arguments and with object arguments', () => { + expect(getToolCallPreviewRevision(preview({ args: { command: 'make test' } }))).not.toBe( + getToolCallPreviewRevision(preview({ args: { command: 'make' } })), + ); + }); +}); + +describe('hasToolCallPreview', () => { + it('reads only the shortening markers', () => { + expect(hasToolCallPreview(preview())).toBe(true); + expect(hasToolCallPreview({ previewRevision: '1' })).toBe(false); + expect(hasToolCallPreview(undefined)).toBe(false); + }); +}); diff --git a/packages/data-provider/src/previews.ts b/packages/data-provider/src/previews.ts index 53300f30f81..42d77f29697 100644 --- a/packages/data-provider/src/previews.ts +++ b/packages/data-provider/src/previews.ts @@ -21,25 +21,40 @@ export type ToolCallPreviewMarkers = Pick< | 'argsLength' | 'subagentContentOmitted' | 'subagentContentParts' + | 'previewRevision' >; /** A tool call as the full-part endpoint returns it, transcript included. */ export type FullToolCall = Agents.ToolCall & { subagent_content?: TMessageContentParts[] }; +/** 32-bit FNV-1a over UTF-16 code units; a fingerprint for cache keys, not a security hash. */ +function fingerprint(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(36); +} + /** - * Identifies the server's current preview of a call. It changes when the stored call does (a - * background result replacing its output, a transcript growing), so a client cache of the full - * part keyed on it never outlives the content it was fetched for. + * Identifies the stored version a preview stands for: the server's `previewRevision` (when the + * message last changed) plus a fingerprint of everything the preview itself carries. Either + * changes when the stored call does, so a client cache of the full part keyed on it never + * outlives the content it was fetched for. */ export function getToolCallPreviewRevision(toolCall: FullToolCall): string { const output = typeof toolCall.output === 'string' ? toolCall.output : ''; - return [ - output.length, + const args = + typeof toolCall.args === 'string' ? toolCall.args : JSON.stringify(toolCall.args ?? null); + const content = [ + output, + args, toolCall.outputLength ?? '', toolCall.argsLength ?? '', toolCall.subagentContentParts ?? '', - output.slice(-48), - ].join(':'); + ].join('\u0000'); + return `${toolCall.previewRevision ?? ''}:${fingerprint(content)}`; } /** True when any of the tool call's content is a preview rather than the stored value. */ diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index df411bcbf48..4527f488eab 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -133,6 +133,9 @@ export namespace Agents { subagentContentOmitted?: true; /** Number of top-level parts in the omitted `subagent_content`. */ subagentContentParts?: number; + /** When the stored message last changed, set on previews so a client cache of the full part + * is keyed to the stored version it came from. */ + previewRevision?: string; /** The tool call was rejected before execution because its input failed schema validation. */ inputValidationError?: true; /** Server-stamped provenance; see `PartMetadata.executor`. */ From 9782a7e47fee6518751d43025a8fdc27581ade86 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 4 Oct 2026 23:03:58 -0400 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A7=BE=20fix:=20Cache=20Fetched=20P?= =?UTF-8?q?arts=20by=20Identity=20and=20Hold=20Copy=20Until=20Hydrated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The part query key drops the client-side index, which only rides along in the request; the server locates the call by message and exact identity, so a shifted index serves the cached copy instead of downloading it again. Copy controls inside a previewed part are disabled until the stored content arrives, so a slow or failed fetch cannot export shortened text. --- .../Chat/Messages/Content/AgentHandoff.tsx | 12 +++++-- .../Chat/Messages/Content/Parts/BashCall.tsx | 9 +++-- .../Content/Parts/CodeWindowHeader.tsx | 14 ++++++-- .../Content/ToolOutput/OutputRenderer.tsx | 8 ++++- .../__tests__/OutputRenderer.test.tsx | 35 +++++++++++++++++-- .../Content/__tests__/hydration.test.tsx | 27 ++++++++++++-- .../Chat/Messages/Content/disclosure.ts | 8 +++++ .../Chat/Messages/Content/hydration.tsx | 6 ++-- .../Messages/Content/ActionButton.tsx | 8 +++-- .../Messages/Content/CopyButton.tsx | 14 +++++++- client/src/data-provider/Messages/queries.ts | 5 +-- 11 files changed, 126 insertions(+), 20 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx index a089643cc0e..6e77e64911b 100644 --- a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -5,7 +5,11 @@ import { Button } from '@librechat/client'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider'; import type { TranslationKeys } from '~/hooks'; -import { toolPanelSpacingClassName, useToolContentRequest } from './disclosure'; +import { + useToolContentRequest, + useToolContentPending, + toolPanelSpacingClassName, +} from './disclosure'; import CopyButton from '~/components/Messages/Content/CopyButton'; import { unescapeJsonString } from './Parts/parseJsonField'; import MessageIcon from '~/components/Share/MessageIcon'; @@ -148,14 +152,15 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = * non-secure origin (a LAN deployment served over http), where * dereferencing it throws before any rejection handler can run and the * button silently does nothing. */ + const contentPending = useToolContentPending(); const handleCopy = useCallback(() => { - if (!copy(copyText, { format: 'text/plain' })) { + if (contentPending || !copy(copyText, { format: 'text/plain' })) { return; } clearTimeout(copiedTimerRef.current); setIsCopied(true); copiedTimerRef.current = setTimeout(() => setIsCopied(false), 2000); - }, [copyText]); + }, [contentPending, copyText]); return (
@@ -215,6 +220,7 @@ const AgentHandoff: React.FC = ({ name, args: _args = '' }) = isCopied={isCopied} iconOnly onClick={handleCopy} + disabled={contentPending} label={localize('com_ui_copy_to_clipboard')} copiedLabel={localize('com_ui_copied_to_clipboard')} /** Only the reveal-on-hover behavior is local; the icon diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx index 860d97480ab..bea741b51e6 100644 --- a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -1,6 +1,7 @@ import { useMemo, useRef, useState, useCallback, useEffect } from 'react'; import copy from 'copy-to-clipboard'; import type { TAttachment, PartMetadata } from 'librechat-data-provider'; +import { toolPanelSpacingClassName, useToolContentPending } from '../disclosure'; import { parseBackgroundHandle, splitBackgroundAttachments } from './handle'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; import parseJsonField, { areToolCallArgsComplete } from './parseJsonField'; @@ -8,7 +9,6 @@ import { useMessagePartsHost } from '~/Providers/MessagePartsHostContext'; import CopyButton from '~/components/Messages/Content/CopyButton'; import LangIcon from '~/components/Messages/Content/LangIcon'; import { PANE_COPY_REVEAL, TOOL_ROW_CLASSES } from '../rows'; -import { toolPanelSpacingClassName } from '../disclosure'; import useToolCallState from './useToolCallState'; import useLazyHighlight from './useLazyHighlight'; import useFollowScroll from './useFollowScroll'; @@ -142,12 +142,16 @@ export default function BashCall({ const timerRef = useRef>(); useEffect(() => () => clearTimeout(timerRef.current), []); + const contentPending = useToolContentPending(); const handleCopy = useCallback(() => { + if (contentPending) { + return; + } setIsCopied(true); copy(command, { format: 'text/plain' }); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setIsCopied(false), 3000); - }, [command]); + }, [command, contentPending]); /** The model-authored `intent` streams as the FIRST args key, so it is the * live label from the earliest delta — before the command exists and while @@ -220,6 +224,7 @@ export default function BashCall({ iconOnly isCopied={isCopied} onClick={handleCopy} + disabled={contentPending} className={cn('bg-surface-code absolute top-1 right-1.5 z-[1]', PANE_COPY_REVEAL)} label={localize('com_ui_copy_code')} /> diff --git a/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx b/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx index d3ab56c4cf4..7917c1f987f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/CodeWindowHeader.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useCallback, useEffect } from 'react'; import copy from 'copy-to-clipboard'; import CopyButton from '~/components/Messages/Content/CopyButton'; import LangIcon from '~/components/Messages/Content/LangIcon'; +import { useToolContentPending } from '../disclosure'; import { useLocalize } from '~/hooks'; interface CodeWindowHeaderProps { @@ -17,12 +18,16 @@ export default function CodeWindowHeader({ language, code, diffStats }: CodeWind useEffect(() => () => clearTimeout(timerRef.current), []); + const contentPending = useToolContentPending(); const handleCopy = useCallback(() => { + if (contentPending) { + return; + } setIsCopied(true); copy(code.trim(), { format: 'text/plain' }); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setIsCopied(false), 3000); - }, [code]); + }, [code, contentPending]); return (
@@ -47,7 +52,12 @@ export default function CodeWindowHeader({ language, code, diffStats }: CodeWind )} - +
); } diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx index bd7df546594..4fe93130709 100644 --- a/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx +++ b/client/src/components/Chat/Messages/Content/ToolOutput/OutputRenderer.tsx @@ -4,6 +4,7 @@ import { Button } from '@librechat/client'; import { hasToolCallErrorPrefix, stripToolCallErrorPrefix } from 'librechat-data-provider'; import type { UIEvent } from 'react'; import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useToolContentPending } from '../disclosure'; import { PANE_COPY_REVEAL } from '../rows'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -151,11 +152,15 @@ export default function OutputRenderer({ followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight <= 8; }, []); + const contentPending = useToolContentPending(); const handleCopy = useCallback(() => { + if (contentPending) { + return; + } setIsCopied(true); copy(copyText ?? displayText, { format: 'text/plain' }); setTimeout(() => setIsCopied(false), 3000); - }, [copyText, displayText]); + }, [contentPending, copyText, displayText]); if (!displayText) { return null; @@ -199,6 +204,7 @@ export default function OutputRenderer({ jest.fn()); @@ -11,12 +12,30 @@ jest.mock('~/hooks', () => ({ jest.mock('~/components/Messages/Content/CopyButton', () => ({ __esModule: true, - default: ({ onClick, className }: { onClick: () => void; className?: string }) => ( -