From 1c26b00325a0312a89df40a992dbf9c219a83d3b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 12:53:22 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=94=90=20fix:=20Skip=20Reasoning=20La?= =?UTF-8?q?bels=20for=20Encrypted=20Reasoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI models on Bedrock (and Claude's redacted thinking on Bedrock) return reasoning as encrypted `redactedContent` bytes. The current agents SDK base64-encodes those bytes into the reasoning stream, so they arrive as THINK text: the reasoning-label wiring summarized the blob ("Inspecting an encoded data snapshot"), activity and phase labels quoted it as reasoning evidence, and the UI rendered it as thinking. Add a shared `stripOpaqueReasoning` / `isOpaqueReasoning` predicate in data-provider that recognizes long unbroken base64/base64url runs (Bedrock redactedContent, Anthropic redacted_thinking data, OpenAI encrypted_content, Gemini thought signatures) and keep them out of every label path: - reasoning labels drop encoded deltas, ignore persisted payloads on resume, and re-check the accumulated text before a billed label call - activity-label and phase-summary evidence carries readable excerpts only; phase anchoring still uses the raw excerpt - the THINK renderer, grouped reasoning rows and the live header show a neutral "Reasoning (encrypted)" marker instead of the payload, and ignore labels previously generated from it Readable reasoning on every provider, including readable text mixed with an encoded payload in the same step, is still labeled. --- .../components/Chat/Messages/Content/Part.tsx | 4 + .../Chat/Messages/Content/Parts/Reasoning.tsx | 24 ++-- .../Parts/__tests__/ReasoningMarker.test.tsx | 33 +++++ .../Chat/Messages/Content/ToolCallGroup.tsx | 14 ++- .../Content/__tests__/LiveParity.test.tsx | 42 +++++++ .../Messages/Content/__tests__/Part.test.tsx | 23 +++- .../Content/__tests__/ToolCallGroup.test.tsx | 22 ++++ .../components/Chat/Messages/Content/live.ts | 17 ++- client/src/locales/en/translation.json | 2 + .../activityLabels/__tests__/wiring.spec.ts | 16 +++ .../api/src/agents/activityLabels/wiring.ts | 4 +- .../src/agents/activityPhases/runtime.spec.ts | 63 ++++++++++ .../api/src/agents/activityPhases/runtime.ts | 20 ++- .../agents/reasoningLabels/runtime.spec.ts | 114 ++++++++++++++++++ .../api/src/agents/reasoningLabels/runtime.ts | 23 +++- packages/data-provider/src/index.ts | 1 + packages/data-provider/src/reasoning.spec.ts | 72 +++++++++++ packages/data-provider/src/reasoning.ts | 20 +++ 18 files changed, 486 insertions(+), 28 deletions(-) create mode 100644 client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningMarker.test.tsx create mode 100644 packages/data-provider/src/reasoning.spec.ts create mode 100644 packages/data-provider/src/reasoning.ts diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index cc0d0a47b45..10536892e7f 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -6,6 +6,7 @@ import { ContentTypes, ToolCallTypes, imageGenTools, + isOpaqueReasoning, isImageVisionTool, hasToolCallPreview, getToolCallPreviewRevision, @@ -646,6 +647,9 @@ const renderers: PartRenderers = { if (reasoning.trim() === '' && part.reasoning_unavailable === true) { return ; } + if (isOpaqueReasoning(reasoning)) { + return ; + } return ( { - const localize = useLocalize(); - const display = label?.trim() || localize('com_ui_thoughts'); - return ; -}); + * subagent projections keep only a marker, and providers can return reasoning + * encrypted. Renders the shared reasoning header row without a disclosure. */ +export const ReasoningMarker = memo( + ({ label, encrypted = false }: { label?: string; encrypted?: boolean }) => { + const localize = useLocalize(); + if (encrypted) { + return ( + + ); + } + const display = label?.trim() || localize('com_ui_thoughts'); + return ; + }, +); ReasoningMarker.displayName = 'ReasoningMarker'; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningMarker.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningMarker.test.tsx new file mode 100644 index 00000000000..bc1009c001f --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningMarker.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ReasoningMarker } from '../Reasoning'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('../Thinking', () => ({ + ThinkingLabel: ({ label, title }: { label: string; title?: string }) => ( +
+ {label} +
+ ), +})); + +describe('ReasoningMarker', () => { + it('shows the generated label for reasoning this view cannot show', () => { + render(); + + const marker = screen.getByTestId('thinking-label'); + expect(marker).toHaveTextContent('Planning the answer'); + expect(marker).toHaveAttribute('title', 'com_ui_thoughts_unavailable'); + }); + + it('shows a neutral encrypted state and ignores any label', () => { + render(); + + const marker = screen.getByTestId('thinking-label'); + expect(marker).toHaveTextContent('com_ui_reasoning_encrypted'); + expect(marker).toHaveAttribute('title', 'com_ui_reasoning_encrypted_description'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index e36555756ec..72250923755 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useMemo, useEffect, useContext, useCallback } from 'react'; -import { Tools, Constants, ContentTypes } from 'librechat-data-provider'; +import { Tools, Constants, ContentTypes, isOpaqueReasoning } from 'librechat-data-provider'; import { ChevronDown, ListChecks, MessageCircleQuestion, TriangleAlert, Users } from 'lucide-react'; import type { TAttachment, TMessageContentParts } from 'librechat-data-provider'; import type { PartWithIndex } from './ParallelContent'; @@ -739,12 +739,16 @@ export default function ToolCallGroup({ const reasoning = typeof think === 'string' ? think : (think?.value ?? ''); /** A detached-subagent projection carries an empty THINK - * part flagged `reasoning_unavailable`, which `Part` - * renders as a `ReasoningMarker`. `ReasoningCompact` has - * no text to show and returns null, so the marker has to + * part flagged `reasoning_unavailable`, and encrypted + * reasoning has no readable text; `Part` renders both as a + * `ReasoningMarker`. `ReasoningCompact` would show the + * encoded payload or return null, so the marker has to * keep going through the standalone path or it vanishes * the moment its call joins a group. */ - if (reasoning.trim() === '' && part.reasoning_unavailable === true) { + if ( + (reasoning.trim() === '' && part.reasoning_unavailable === true) || + isOpaqueReasoning(reasoning) + ) { return renderPart( part, idx, diff --git a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx index 65ba73a79f3..82d01268485 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx @@ -1706,3 +1706,45 @@ it.each(['lookup', 'image_gen_oai'])( } }, ); + +describe('live activity over encrypted reasoning', () => { + /** Bedrock `redactedContent`, base64-encoded into THINK text by the SDK. */ + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + const think = (text: string, label?: string): TMessageContentParts => + ({ + type: ContentTypes.THINK, + think: text, + ...(label != null && { reasoning_label: label }), + }) as TMessageContentParts; + + it('names an encrypted thought generically instead of previewing the payload', () => { + const activity = getLiveActivity( + [think(encoded, 'Inspecting an encoded data snapshot')], + (key) => key, + [], + ); + expect(activity.text).toBe('com_ui_thinking'); + }); + + it('never titles a span with a label generated from an encrypted thought', () => { + const activity = getLiveActivity( + [think(encoded, 'Inspecting an encoded data snapshot')], + (key) => key, + [], + undefined, + true, + ); + expect(activity.text).toBe('com_ui_running'); + }); + + it('still previews readable reasoning', () => { + const activity = getLiveActivity( + [think('Comparing both lockfiles. The resolver pins an older range.')], + (key) => key, + [], + ); + expect(activity.text).toBe('The resolver pins an older range.'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx index c4488db080d..91d4e507660 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx @@ -10,8 +10,10 @@ jest.mock('../Parts', () => ({ AgentUpdate: () =>
, EmptyText: () =>
, Reasoning: () =>
, - ReasoningMarker: ({ label }: { label?: string }) => ( -
{label}
+ ReasoningMarker: ({ label, encrypted }: { label?: string; encrypted?: boolean }) => ( +
+ {label} +
), Summary: () =>
, Text: ({ text }: { text?: string }) =>
{text}
, @@ -280,4 +282,21 @@ describe('Part tool renderer selection', () => { expect(screen.getByTestId('reasoning')).toBeInTheDocument(); expect(screen.queryByTestId('reasoning-marker')).not.toBeInTheDocument(); }); + + it('renders encrypted reasoning as an encrypted marker without its stale label', () => { + /** Bedrock `redactedContent`, base64-encoded into THINK text by the SDK. */ + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + renderPart({ + type: ContentTypes.THINK, + think: encoded, + reasoning_label: 'Inspecting an encoded data snapshot', + } as TMessageContentParts); + + const marker = screen.getByTestId('reasoning-marker'); + expect(marker).toHaveAttribute('data-encrypted', 'true'); + expect(marker).not.toHaveTextContent('Inspecting an encoded data snapshot'); + expect(screen.queryByTestId('reasoning')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx index 187d394bd70..7d80c57167b 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/ToolCallGroup.test.tsx @@ -601,6 +601,28 @@ describe('ToolCallGroup image hoisting', () => { expect(screen.queryByTestId('compact-reasoning')).not.toBeInTheDocument(); }); + it('delegates an encrypted-reasoning part to the standalone renderer', () => { + const encryptedPart = { + type: ContentTypes.THINK, + [ContentTypes.THINK]: Buffer.from( + Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256), + ).toString('base64'), + } as TMessageContentParts; + + renderGroup({ + ...baseProps, + parts: [ + { part: makePart('t1'), idx: 0 }, + { part: encryptedPart, idx: 1 }, + ], + }); + + fireEvent.click(screen.getByRole('button', { name: /^Fetch_image/ })); + + expect(screen.getByTestId('inner-1')).toBeInTheDocument(); + expect(screen.queryByTestId('compact-reasoning')).not.toBeInTheDocument(); + }); + it('unmounts tool bodies after a collapsed group finishes transitioning', () => { renderGroup(baseProps); diff --git a/client/src/components/Chat/Messages/Content/live.ts b/client/src/components/Chat/Messages/Content/live.ts index 0f43fe5797b..924423f009c 100644 --- a/client/src/components/Chat/Messages/Content/live.ts +++ b/client/src/components/Chat/Messages/Content/live.ts @@ -1,4 +1,9 @@ -import { Constants, ContentTypes, stripToolCallErrorPrefix } from 'librechat-data-provider'; +import { + Constants, + ContentTypes, + isOpaqueReasoning, + stripToolCallErrorPrefix, +} from 'librechat-data-provider'; import type { Agents, TAttachment, @@ -308,8 +313,12 @@ function newestLine( * Unless the fold is open: the text is on screen then, and a header * repeating a line of it under the reader's eyes is noise, so the * header keeps to the thought's label. */ - const reasoning = typeof part.think === 'string' ? part.think : (part.think?.value ?? ''); - const label = part.reasoning_label?.trim(); + const think = typeof part.think === 'string' ? part.think : (part.think?.value ?? ''); + /** Encrypted reasoning has no sentence to preview, and a label generated + * from it before labels skipped encoded payloads describes the payload. */ + const opaque = isOpaqueReasoning(think); + const reasoning = opaque ? '' : think; + const label = opaque ? undefined : part.reasoning_label?.trim(); if (preferLabels) { /** Only a generated label will do: the generic thinking line is what * the grouped thought row itself says. */ @@ -322,7 +331,7 @@ function newestLine( if (sentence != null) { return { text: sentence, source: `think:${position}`, comboCount: 1 }; } - if (label || reasoning.trim()) { + if (label || think.trim()) { return { text: label || localize('com_ui_thinking'), source: `think:${position}`, diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index d6c148b2a40..9053d99a33e 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1337,6 +1337,8 @@ "com_ui_authentication_type": "Authentication Type", "com_ui_auto": "Auto", "com_ui_reasoning_for_next_message": "Reasoning for next message", + "com_ui_reasoning_encrypted": "Reasoning (encrypted)", + "com_ui_reasoning_encrypted_description": "The model returned its reasoning encrypted, so it cannot be shown", "com_ui_available_options": "Available options", "com_ui_avatar": "Avatar", "com_ui_avatar_drop_image": "Drop an image here to use as your profile picture", diff --git a/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts b/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts index 0e2aaba489a..f5232204a29 100644 --- a/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts +++ b/packages/api/src/agents/activityLabels/__tests__/wiring.spec.ts @@ -123,6 +123,22 @@ describe('captureActivityBlockContext', () => { const context = captureActivityBlockContext(parts, 'agent-a'); expect(context.thinkingExcerpts).toEqual(['Agent A reasoning']); }); + + it('keeps encoded provider reasoning out of the label evidence', () => { + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + const parts: LooseContentPart[] = [ + { type: 'think', think: 'Comparing both lockfiles before the upgrade.' }, + { type: 'think', think: encoded }, + { type: 'think', think: `Pinning the resolver.\n${encoded}` }, + ]; + const context = captureActivityBlockContext(parts); + expect(context.thinkingExcerpts).toEqual([ + 'Comparing both lockfiles before the upgrade.', + 'Pinning the resolver.', + ]); + }); }); describe('stripActivityLabelParts', () => { diff --git a/packages/api/src/agents/activityLabels/wiring.ts b/packages/api/src/agents/activityLabels/wiring.ts index 35c09059004..335f8217abb 100644 --- a/packages/api/src/agents/activityLabels/wiring.ts +++ b/packages/api/src/agents/activityLabels/wiring.ts @@ -1,4 +1,4 @@ -import { ContentTypes } from 'librechat-data-provider'; +import { ContentTypes, stripOpaqueReasoning } from 'librechat-data-provider'; import type { HookCallback } from '@librechat/agents'; import type { ActivityLabelBlockContext, @@ -83,7 +83,7 @@ export function captureActivityBlockContext( thinkingExcerpts.length < MAX_EXCERPTS && (executingAgentId == null || part.agentId == null || part.agentId === executingAgentId) ) { - const think = textValue(part.think).trim(); + const think = stripOpaqueReasoning(textValue(part.think)).trim(); if (think.length > 0) { thinkingExcerpts.unshift(think.slice(0, EXCERPT_CHARS)); } diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index 44a451c6068..20bfaf61ce1 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -492,6 +492,69 @@ describe('createActivityPhaseWiring', () => { expect(payloads[1]).toMatchObject({ assistantContext: ['Context for the later work.'] }); }); + it('sends only readable reasoning excerpts to the phase summarizer', async () => { + const encoded = Buffer.from(Array.from({ length: 450 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + const parts: LooseContentPart[] = [ + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } }, + ]; + const payloads: GenerateActivityPhasePayload[] = []; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 3, + generated: 0, + activityCount: 3, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [ + { + startIndex: 0, + status: 'success', + toolCallIds: ['tool-1'], + thinkingExcerpts: ['Checking the deploy manifest.'], + }, + { + startIndex: 1, + status: 'success', + toolCallIds: ['tool-2'], + thinkingExcerpts: [encoded], + }, + { + startIndex: 2, + status: 'success', + toolCallIds: ['tool-3'], + thinkingExcerpts: [`Rolling back.\n${encoded}`], + }, + ], + assistantContext: [], + pendingReasoning: [], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async (payload: GenerateActivityPhasePayload) => { + payloads.push(payload); + return { label: 'Rolled back the deploy' }; + }), + }); + + wiring.complete(); + await flushDetached(); + + expect(payloads).toHaveLength(1); + expect(payloads[0].activities.map((activity) => activity.thinkingExcerpts)).toEqual([ + ['Checking the deploy manifest.'], + undefined, + ['Rolling back.'], + ]); + expect(payloads[0].activities[1]).not.toHaveProperty('thinkingExcerpts'); + }); + it('keeps a substantial boundary hard when too little preceding work earns a phase', async () => { const parts: LooseContentPart[] = [ { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index 1d0228f828e..c3c454cbc14 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -1,5 +1,5 @@ import { GraphEvents } from '@librechat/agents'; -import { ContentTypes, StepTypes } from 'librechat-data-provider'; +import { ContentTypes, StepTypes, stripOpaqueReasoning } from 'librechat-data-provider'; import type { EventHandler, HookCallback, HookInputByEvent } from '@librechat/agents'; import type { LooseContentPart } from '~/agents/activityLabels/wiring'; import { stringifyActivityEvidence } from '~/agents/activityLabels/runtime'; @@ -196,6 +196,18 @@ function deltaText(data: unknown, key: 'text' | 'think'): string { return parts.map((part) => textValue((part as DeltaPart | null)?.[key])).join(''); } +/** Prompt evidence only: anchors keep the raw excerpt so positions still resolve. */ +function withReadableReasoning(activity: ActivityPhaseEntry): ActivityPhaseEntry { + if (activity.thinkingExcerpts == null) { + return activity; + } + const { thinkingExcerpts, ...rest } = activity; + const readable = thinkingExcerpts + .map((excerpt) => stripOpaqueReasoning(excerpt).trim()) + .filter((excerpt) => excerpt.length > 0); + return readable.length > 0 ? { ...rest, thinkingExcerpts: readable } : rest; +} + function buildSignal(signal?: AbortSignal): AbortSignal { const timeout = AbortSignal.timeout(PHASE_TIMEOUT_MS); return signal != null && typeof AbortSignal.any === 'function' @@ -1077,11 +1089,13 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity .find(matchesToolIds); } if (!matchesToolIds(child)) { - return activity; + return withReadableReasoning(activity); } const label = child?.pending !== true ? textValue(child?.[ContentTypes.ACTIVITY_LABEL]).trim() : ''; - return label ? { ...activity, label, entries: undefined } : activity; + return withReadableReasoning( + label ? { ...activity, label, entries: undefined } : activity, + ); }, ); }; diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index 20aea85c126..bf6c698a122 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -873,3 +873,117 @@ describe('reasoning labels', () => { ]); }); }); + +/** Bedrock's Converse stream returns OpenAI (and redacted Anthropic) reasoning + * as `reasoningContent.redactedContent` bytes; the SDK base64-encodes them and + * surfaces the result as THINK text. */ +const encodedReasoning = (bytes = 1_050, seed = 7): string => + Buffer.from(Array.from({ length: bytes }, (_, i) => (i * 37 + seed) % 256)).toString('base64'); + +describe('reasoning labels for encoded reasoning', () => { + const config = { minChars: 10, updateChars: 8, updateIntervalMs: 0 }; + + it('never labels a step whose reasoning is an encoded payload', async () => { + const generate = jest.fn(async () => ({ label: 'Inspecting an encoded data snapshot' })); + const harness = createHarness(generate, config); + await harness.start(); + await harness.append(encodedReasoning()); + await harness.append(encodedReasoning(300, 11)); + await harness.close(); + await harness.settle(); + + expect(generate).not.toHaveBeenCalled(); + expect(harness.attemptEvents).toEqual([]); + expect(harness.events).toEqual([]); + expect(harness.parts[0]?.reasoning_label).toBeUndefined(); + }); + + it('skips an encoded payload that arrives split across small deltas', async () => { + const generate = jest.fn(async () => ({ label: 'Inspecting an encoded data snapshot' })); + const harness = createHarness(generate, { updateChars: 400, updateIntervalMs: 0 }); + const payload = encodedReasoning(); + await harness.start(); + for (let offset = 0; offset < payload.length; offset += 100) { + await harness.append(payload.slice(offset, offset + 100)); + } + await harness.close(); + await harness.settle(); + + expect(generate).not.toHaveBeenCalled(); + expect(harness.attemptEvents).toEqual([]); + }); + + it('labels mixed reasoning from its readable text only', async () => { + const generate = jest.fn(async () => ({ label: 'Comparing retry backoff strategies' })); + const harness = createHarness(generate, { ...config, updateChars: 1_000 }); + const payload = encodedReasoning(); + await harness.start(); + await harness.append('Comparing the retry policy against the queue backoff. '); + await harness.settle(); + await harness.append(payload); + await harness.append('Exponential backoff fits the burst pattern, so the queue keeps it. '); + await harness.append( + 'Jitter spreads the retries across the window to avoid a thundering herd.', + ); + await harness.close(); + await harness.settle(); + + expect(generate).toHaveBeenCalledTimes(2); + for (const [payloadArg] of generate.mock.calls as unknown as Array< + [{ visibleReasoning: string }] + >) { + expect(payloadArg.visibleReasoning).not.toContain(payload.slice(0, 64)); + } + expect(generate).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: 'complete', + visibleReasoning: expect.stringContaining('backoff. Exponential backoff fits'), + }), + ); + expect(harness.parts[0]?.reasoning_label).toBe('Comparing retry backoff strategies'); + }); + + it('still labels readable reasoning of the same length', async () => { + const generate = jest.fn(async () => ({ label: 'Planning the cache migration' })); + const harness = createHarness(generate, config); + const readable = + 'Planning the cache migration: move session keys first, then rebuild the index. '; + await harness.start(); + await harness.append(readable.repeat(18)); + await harness.close(); + await harness.settle(); + + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ visibleReasoning: expect.stringContaining('Planning the cache') }), + ); + expect(harness.parts[0]?.reasoning_label).toBe('Planning the cache migration'); + }); + + it('ignores a persisted encoded payload when a resumed step continues', async () => { + const generate = jest.fn(async () => ({ label: 'Checking the resumed tool result' })); + const harness = createHarness(generate, { + ...config, + preservePartOnStart: true, + initialParts: [ + { + type: ContentTypes.THINK, + think: encodedReasoning(), + reasoning_label_step_id: 'reasoning-1', + reasoning_label_attempts: 0, + }, + ], + }); + await harness.start(); + await harness.settle(); + expect(generate).not.toHaveBeenCalled(); + + await harness.append('Checking the tool result.'); + await harness.close(); + await harness.settle(); + + expect(generate).toHaveBeenCalledTimes(1); + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ visibleReasoning: 'Checking the tool result.' }), + ); + }); +}); diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index 8bb8c9e731f..ed7117c33d3 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -1,5 +1,5 @@ import { GraphEvents } from '@librechat/agents'; -import { ContentTypes, StepTypes } from 'librechat-data-provider'; +import { ContentTypes, StepTypes, stripOpaqueReasoning } from 'librechat-data-provider'; import type { EventHandler } from '@librechat/agents'; import type { LooseContentPart } from '~/agents/activityLabels/wiring'; @@ -390,9 +390,18 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } clearTimer(state); + /** Per-delta stripping misses an encoded payload split across deltas; + * the accumulated text is the last gate before a billed provider call. */ + const visibleReasoning = stripOpaqueReasoning(state.text).trim(); + if (!visibleReasoning) { + if (state.pendingFinal) { + state.pendingFinal = false; + markComplete(state); + } + return; + } const status: ReasoningLabelStatus = state.pendingFinal ? 'complete' : 'streaming'; state.pendingFinal = false; - const visibleReasoning = state.text.trim(); state.submittedChars = state.totalChars; state.lastSubmittedAt = now(); generated += 1; @@ -535,7 +544,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason ownsExistingStep && typeof part.reasoning_label === 'string' ? part.reasoning_label : undefined; - const existingText = ownsExistingStep ? textValue(part?.think) : ''; + const existingText = ownsExistingStep ? stripOpaqueReasoning(textValue(part?.think)) : ''; const persistedSubmittedChars = ownsExistingStep && typeof part.reasoning_label_submitted_chars === 'number' ? Math.min(part.reasoning_label_submitted_chars, existingText.trim().length) @@ -583,8 +592,8 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason const appendDelta = (data: unknown): void => { const event = data as { id?: string }; - const text = deltaText(data); - if (!event.id || !text) { + const rawText = deltaText(data); + if (!event.id || !rawText) { return; } const state = steps.get(event.id); @@ -608,6 +617,10 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason : {}), }); } + const text = stripOpaqueReasoning(rawText); + if (!text) { + return; + } state.text = appendBoundedReasoning(state.text, text); state.totalChars += text.length; schedule(state); diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index 3238b8ddab6..1c9dc09f08e 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -17,6 +17,7 @@ export * from './errors'; export * from './runSteps'; /* ui parts */ export * from './parts'; +export * from './reasoning'; export * from './toolTiming'; /* artifacts */ export * from './artifacts'; diff --git a/packages/data-provider/src/reasoning.spec.ts b/packages/data-provider/src/reasoning.spec.ts new file mode 100644 index 00000000000..f9d4495732a --- /dev/null +++ b/packages/data-provider/src/reasoning.spec.ts @@ -0,0 +1,72 @@ +import { isOpaqueReasoning, stripOpaqueReasoning } from './reasoning'; + +const bytes = (length: number, seed: number): Buffer => + Buffer.from(Array.from({ length }, (_, i) => (i * 37 + seed) % 256)); + +/** Bedrock Converse `reasoningContent.redactedContent`, base64-encoded by the SDK + * (OpenAI models on Bedrock, and Claude's redacted thinking on Bedrock). */ +const bedrockRedacted = bytes(1_050, 3).toString('base64'); +/** Anthropic `redacted_thinking.data`. */ +const anthropicRedacted = `EmwKAhgBEgy${bytes(600, 5).toString('base64')}`; +/** OpenAI Responses `reasoning.encrypted_content` (base64url Fernet token). */ +const openAIEncrypted = `gAAAAABo${bytes(900, 9).toString('base64url')}`; +/** Gemini `thoughtSignature`. */ +const geminiSignature = `CiQB${bytes(300, 13).toString('base64')}`; + +const readable = [ + 'The user wants the deploy rolled back. First I will check which revision is live, then compare it with the last healthy one.', + 'Plan:\n1. read `config/app.ts`\n2. patch `getUser()`\n3. run `npm test -- --watch=false`', + 'The docs live at https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/agents#reasoning-labels and say so.', + 'Short', + '42', +]; + +describe('isOpaqueReasoning', () => { + it.each([ + ['Bedrock redactedContent', bedrockRedacted], + ['Anthropic redacted_thinking', anthropicRedacted], + ['OpenAI encrypted_content', openAIEncrypted], + ['Gemini thought signature', geminiSignature], + ['concatenated Bedrock deltas', `${bedrockRedacted}${bytes(400, 21).toString('base64')}`], + ['payload with surrounding whitespace', `\n ${bedrockRedacted}\n`], + ])('classifies %s as opaque', (_name, text) => { + expect(isOpaqueReasoning(text)).toBe(true); + }); + + it.each(readable.map((text) => [text.slice(0, 40), text]))( + 'keeps readable text: %s', + (_n, text) => { + expect(isOpaqueReasoning(text)).toBe(false); + }, + ); + + it('does not treat empty or whitespace-only reasoning as opaque', () => { + expect(isOpaqueReasoning('')).toBe(false); + expect(isOpaqueReasoning(' \n\t')).toBe(false); + }); + + it('treats readable reasoning that quotes an encoded value as readable', () => { + expect(isOpaqueReasoning(`The header carries ${openAIEncrypted}, so the token is set.`)).toBe( + false, + ); + }); +}); + +describe('stripOpaqueReasoning', () => { + it('removes encoded payloads and keeps the readable prose around them', () => { + const text = `Checking the retry policy.\n${bedrockRedacted}\nBackoff fits the burst pattern.`; + expect(stripOpaqueReasoning(text)).toBe( + 'Checking the retry policy.\n\nBackoff fits the burst pattern.', + ); + }); + + it('leaves readable reasoning unchanged', () => { + for (const text of readable) { + expect(stripOpaqueReasoning(text)).toBe(text); + } + }); + + it('returns an empty string for a payload-only text', () => { + expect(stripOpaqueReasoning(anthropicRedacted)).toBe(''); + }); +}); diff --git a/packages/data-provider/src/reasoning.ts b/packages/data-provider/src/reasoning.ts new file mode 100644 index 00000000000..17e433e825f --- /dev/null +++ b/packages/data-provider/src/reasoning.ts @@ -0,0 +1,20 @@ +/** + * Shortest unbroken base64/base64url run treated as an encoded provider + * payload instead of reasoning prose. Natural-language reasoning breaks far + * sooner on whitespace or punctuation, while encrypted reasoning (Bedrock + * `redactedContent`, Anthropic `redacted_thinking.data`, OpenAI + * `encrypted_content`, signatures) is one long run of these characters. + */ +const OPAQUE_REASONING_MIN_RUN = 128; +const OPAQUE_REASONING_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${OPAQUE_REASONING_MIN_RUN},}`, 'g'); + +/** Removes encoded/encrypted provider payloads from reasoning text, keeping only readable prose. */ +export function stripOpaqueReasoning(text: string): string { + return text.replace(OPAQUE_REASONING_RUN, ''); +} + +/** True when reasoning text has content but none of it is human-readable. */ +export function isOpaqueReasoning(text: string): boolean { + const trimmed = text.trim(); + return trimmed.length > 0 && stripOpaqueReasoning(trimmed).trim().length === 0; +} From cb8ec47fdc3791754bb6afb13833c60c81310f42 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 13:10:54 -0400 Subject: [PATCH 2/9] fix: Classify streamed and persisted payloads by readable content - isOpaqueReasoning / stripOpaqueReasoning ignore the persisted `` wrapper, so stored encrypted reasoning renders as the encrypted marker and never reaches label evidence as tags-only "text" - add advanceOpaqueReasoning, a streaming cursor that holds a trailing payload-alphabet run until it settles as a word or grows into a payload, so a payload split across sub-threshold deltas is dropped as it streams - reasoning labels count only settled readable characters plus a word-sized pending tail, so ciphertext fragments never advance the minChars or updateChars gates or trigger extra billed label calls --- .../Messages/Content/__tests__/Part.test.tsx | 13 +++ .../agents/reasoningLabels/runtime.spec.ts | 27 ++++++- .../api/src/agents/reasoningLabels/runtime.ts | 54 ++++++++++--- packages/data-provider/src/reasoning.spec.ts | 61 +++++++++++++- packages/data-provider/src/reasoning.ts | 79 ++++++++++++++++++- 5 files changed, 216 insertions(+), 18 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx index 91d4e507660..615e6f16ed5 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/Part.test.tsx @@ -299,4 +299,17 @@ describe('Part tool renderer selection', () => { expect(marker).not.toHaveTextContent('Inspecting an encoded data snapshot'); expect(screen.queryByTestId('reasoning')).not.toBeInTheDocument(); }); + + it('renders persisted encrypted reasoning inside think tags as an encrypted marker', () => { + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + renderPart({ + type: ContentTypes.THINK, + think: `${encoded}`, + } as TMessageContentParts); + + expect(screen.getByTestId('reasoning-marker')).toHaveAttribute('data-encrypted', 'true'); + expect(screen.queryByTestId('reasoning')).not.toBeInTheDocument(); + }); }); diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index bf6c698a122..9ca3a51f4c6 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -356,7 +356,7 @@ describe('reasoning labels', () => { await harness.start(); await harness.append('12345'); await streamingEmit; - await harness.append('x'.repeat(120)); + await harness.append(' word'.repeat(24)); await harness.close(); await terminalEmit; await new Promise((resolve) => setImmediate(resolve)); @@ -556,7 +556,7 @@ describe('reasoning labels', () => { await harness.start(); await harness.append('12345'); await harness.settle(); - await harness.append('x'.repeat(120)); + await harness.append(' word'.repeat(24)); await harness.close(); await harness.settle(); @@ -913,6 +913,29 @@ describe('reasoning labels for encoded reasoning', () => { expect(harness.attemptEvents).toEqual([]); }); + it('never spends label revisions on a payload split across small deltas', async () => { + const generate = jest.fn(async () => ({ label: 'Checking the retry policy' })); + const harness = createHarness(generate, { minChars: 10, updateChars: 50, updateIntervalMs: 0 }); + const payload = encodedReasoning(); + await harness.start(); + await harness.append('Checking the retry policy against the queue backoff. '); + await harness.settle(); + expect(generate).toHaveBeenCalledTimes(1); + + for (let offset = 0; offset < payload.length; offset += 100) { + await harness.append(payload.slice(offset, offset + 100)); + await harness.settle(); + } + await harness.close(); + await harness.settle(); + + expect(generate).toHaveBeenCalledTimes(1); + expect(harness.parts[0]).toMatchObject({ + reasoning_label: 'Checking the retry policy', + reasoning_label_status: 'complete', + }); + }); + it('labels mixed reasoning from its readable text only', async () => { const generate = jest.fn(async () => ({ label: 'Comparing retry backoff strategies' })); const harness = createHarness(generate, { ...config, updateChars: 1_000 }); diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index ed7117c33d3..8ff79f6edc4 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -1,5 +1,12 @@ import { GraphEvents } from '@librechat/agents'; -import { ContentTypes, StepTypes, stripOpaqueReasoning } from 'librechat-data-provider'; +import { + StepTypes, + ContentTypes, + stripOpaqueReasoning, + advanceOpaqueReasoning, + OPAQUE_REASONING_START, +} from 'librechat-data-provider'; +import type { OpaqueReasoningCursor } from 'librechat-data-provider'; import type { EventHandler } from '@librechat/agents'; import type { LooseContentPart } from '~/agents/activityLabels/wiring'; @@ -76,8 +83,12 @@ interface ReasoningStepState { stepId: string; agentId?: string; index?: number; + /** Readable reasoning only; encoded provider payloads never enter it. */ text: string; + /** Settled readable characters, so encoded payloads never advance the gates. */ totalChars: number; + /** Trailing run not yet classified as a word or a payload (under 128 chars). */ + cursor: OpaqueReasoningCursor; label?: string; labelStatus?: ReasoningLabelStatus; revision: number; @@ -101,6 +112,7 @@ const MAX_TRACKED_REASONING_CHARS = 8_000; * completion rewrite; a smaller tail only upgrades the current status. */ const FINAL_UPDATE_CHAR_LIMIT = 120; const OUTPUT_CHAR_LIMIT = 120; +const MAX_PENDING_WORD_CHARS = 32; const REQUEST_TIMEOUT_MS = 12_000; function textValue(value: unknown): string { @@ -125,6 +137,17 @@ function deltaText(data: unknown): string { .join(''); } +/** The undecided tail when it is word-sized. A longer tail is more likely the + * start of a payload split across deltas, so it waits until it settles. */ +function pendingWord(state: Pick): string { + const { tail } = state.cursor; + return tail.length <= MAX_PENDING_WORD_CHARS ? tail : ''; +} + +function visibleChars(state: Pick): number { + return state.totalChars + pendingWord(state).length; +} + function normalizeLabel(value: string | undefined): string { const firstLine = value?.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ''; const normalized = firstLine @@ -334,7 +357,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason }; const shouldGenerate = (state: ReasoningStepState, final: boolean): boolean => { - const length = state.totalChars; + const length = visibleChars(state); if (!state.label) { if (length < minChars) { return false; @@ -390,9 +413,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } clearTimer(state); - /** Per-delta stripping misses an encoded payload split across deltas; - * the accumulated text is the last gate before a billed provider call. */ - const visibleReasoning = stripOpaqueReasoning(state.text).trim(); + const visibleReasoning = `${state.text}${pendingWord(state)}`.trim(); if (!visibleReasoning) { if (state.pendingFinal) { state.pendingFinal = false; @@ -402,7 +423,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } const status: ReasoningLabelStatus = state.pendingFinal ? 'complete' : 'streaming'; state.pendingFinal = false; - state.submittedChars = state.totalChars; + state.submittedChars = visibleChars(state); state.lastSubmittedAt = now(); generated += 1; state.attempts += 1; @@ -488,7 +509,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } })().finally(() => { state.inFlight = undefined; - if (state.closed || state.totalChars - state.submittedChars >= updateChars) { + if (state.closed || visibleChars(state) - state.submittedChars >= updateChars) { schedule(state, state.closed); } }); @@ -503,6 +524,12 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } state.closed = true; clearTimer(state); + const word = pendingWord(state); + if (word) { + state.text = appendBoundedReasoning(state.text, word); + state.totalChars += word.length; + } + state.cursor = OPAQUE_REASONING_START; schedule(state, true); }; @@ -563,6 +590,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason ...(index != null && { index }), text: appendBoundedReasoning('', existingText), totalChars: existingText.trim().length, + cursor: OPAQUE_REASONING_START, ...(existingLabel != null && { label: existingLabel }), ...(existingLabel != null && { labelStatus: part?.reasoning_label_status === 'complete' ? 'complete' : 'streaming', @@ -617,12 +645,16 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason : {}), }); } - const text = stripOpaqueReasoning(rawText); - if (!text) { + const previousTail = state.cursor.tail; + const { readable: text, cursor } = advanceOpaqueReasoning(state.cursor, rawText); + state.cursor = cursor; + if (!text && cursor.tail === previousTail) { return; } - state.text = appendBoundedReasoning(state.text, text); - state.totalChars += text.length; + if (text) { + state.text = appendBoundedReasoning(state.text, text); + state.totalChars += text.length; + } schedule(state); }; diff --git a/packages/data-provider/src/reasoning.spec.ts b/packages/data-provider/src/reasoning.spec.ts index f9d4495732a..24ddc301d41 100644 --- a/packages/data-provider/src/reasoning.spec.ts +++ b/packages/data-provider/src/reasoning.spec.ts @@ -1,4 +1,10 @@ -import { isOpaqueReasoning, stripOpaqueReasoning } from './reasoning'; +import type { OpaqueReasoningCursor } from './reasoning'; +import { + isOpaqueReasoning, + stripOpaqueReasoning, + advanceOpaqueReasoning, + OPAQUE_REASONING_START, +} from './reasoning'; const bytes = (length: number, seed: number): Buffer => Buffer.from(Array.from({ length }, (_, i) => (i * 37 + seed) % 256)); @@ -40,6 +46,17 @@ describe('isOpaqueReasoning', () => { }, ); + it('classifies a payload inside the persisted think wrapper as opaque', () => { + expect(isOpaqueReasoning(`${bedrockRedacted}`)).toBe(true); + expect( + isOpaqueReasoning(` +${anthropicRedacted} +`), + ).toBe(true); + expect(isOpaqueReasoning('Checking the manifest.')).toBe(false); + expect(isOpaqueReasoning('')).toBe(false); + }); + it('does not treat empty or whitespace-only reasoning as opaque', () => { expect(isOpaqueReasoning('')).toBe(false); expect(isOpaqueReasoning(' \n\t')).toBe(false); @@ -68,5 +85,47 @@ describe('stripOpaqueReasoning', () => { it('returns an empty string for a payload-only text', () => { expect(stripOpaqueReasoning(anthropicRedacted)).toBe(''); + expect(stripOpaqueReasoning(`${anthropicRedacted}`)).toBe(''); + }); +}); + +describe('advanceOpaqueReasoning', () => { + const stream = (deltas: string[]): { readable: string; cursor: OpaqueReasoningCursor } => + deltas.reduce( + (acc, delta) => { + const next = advanceOpaqueReasoning(acc.cursor, delta); + return { readable: acc.readable + next.readable, cursor: next.cursor }; + }, + { readable: '', cursor: OPAQUE_REASONING_START }, + ); + const chunks = (text: string, size: number): string[] => + Array.from({ length: Math.ceil(text.length / size) }, (_, i) => + text.slice(i * size, (i + 1) * size), + ); + + it('passes readable token deltas through, holding only the unfinished word', () => { + const result = stream(['Let', ' me', ' check', ' the', ' manifest.', ' Then', ' roll']); + expect(result.readable).toBe('Let me check the manifest. Then '); + expect(result.cursor).toEqual({ tail: 'roll', opaque: false }); + }); + + it('drops a payload delivered as one delta', () => { + expect(stream([bedrockRedacted]).readable).toBe(''); + }); + + it('drops a payload split into deltas shorter than the threshold', () => { + const result = stream(chunks(bedrockRedacted, 50)); + expect(result.readable).toBe(''); + expect(result.cursor.opaque).toBe(true); + }); + + it('keeps readable reasoning on both sides of a split payload', () => { + const result = stream([ + 'Checking the retry policy. ', + ...chunks(bedrockRedacted, 100), + 'Backoff fits the burst pattern.', + ]); + expect(result.readable).toBe('Checking the retry policy. Backoff fits the burst pattern.'); + expect(result.cursor).toEqual({ tail: '', opaque: false }); }); }); diff --git a/packages/data-provider/src/reasoning.ts b/packages/data-provider/src/reasoning.ts index 17e433e825f..a0f504a8af5 100644 --- a/packages/data-provider/src/reasoning.ts +++ b/packages/data-provider/src/reasoning.ts @@ -7,14 +7,85 @@ */ const OPAQUE_REASONING_MIN_RUN = 128; const OPAQUE_REASONING_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${OPAQUE_REASONING_MIN_RUN},}`, 'g'); +/** Persisted reasoning is stored inside `` tags; they are not readable content. */ +const THINK_TAGS = /<\/?think>/g; -/** Removes encoded/encrypted provider payloads from reasoning text, keeping only readable prose. */ +function isPayloadChar(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + code === 43 || + code === 47 || + code === 61 || + code === 95 || + code === 45 + ); +} + +/** + * Removes encoded/encrypted provider payloads from reasoning text, keeping only + * readable prose. Returns an empty string when nothing but the persisted + * `` wrapper would remain. + */ export function stripOpaqueReasoning(text: string): string { - return text.replace(OPAQUE_REASONING_RUN, ''); + const stripped = text.replace(OPAQUE_REASONING_RUN, ''); + if (stripped.length === text.length) { + return text; + } + return stripped.replace(THINK_TAGS, '').trim().length === 0 ? '' : stripped; } /** True when reasoning text has content but none of it is human-readable. */ export function isOpaqueReasoning(text: string): boolean { - const trimmed = text.trim(); - return trimmed.length > 0 && stripOpaqueReasoning(trimmed).trim().length === 0; + return text.replace(THINK_TAGS, '').trim().length > 0 && stripOpaqueReasoning(text) === ''; +} + +/** Streaming position of {@link advanceOpaqueReasoning}: an undecided trailing run, or an open payload. */ +export interface OpaqueReasoningCursor { + /** Trailing payload-alphabet characters too short to classify yet. */ + readonly tail: string; + /** True while the stream is inside a run already long enough to be a payload. */ + readonly opaque: boolean; +} + +export const OPAQUE_REASONING_START: OpaqueReasoningCursor = { tail: '', opaque: false }; + +function isPayloadOnly(text: string): boolean { + for (let i = 0; i < text.length; i += 1) { + if (!isPayloadChar(text.charCodeAt(i))) { + return false; + } + } + return true; +} + +/** + * Feeds one reasoning delta through the payload filter. A payload can arrive + * split across deltas shorter than the classification threshold, so a trailing + * run is held in the cursor until a non-payload character settles it; readable + * words are held only until the next space or punctuation. Once a run is known + * to be a payload, deltas made only of payload characters continue it, and the + * first delta with any other character starts fresh, since providers stream a + * payload and the reasoning after it as separate blocks. Returns the settled + * readable text, which callers can count and accumulate directly. + */ +export function advanceOpaqueReasoning( + cursor: OpaqueReasoningCursor, + delta: string, +): { readable: string; cursor: OpaqueReasoningCursor } { + if (cursor.opaque && isPayloadOnly(delta)) { + return { readable: '', cursor }; + } + const combined = `${cursor.opaque ? '' : cursor.tail}${delta}`; + let end = combined.length; + while (end > 0 && isPayloadChar(combined.charCodeAt(end - 1))) { + end -= 1; + } + const trailing = combined.slice(end); + const readable = combined.slice(0, end).replace(OPAQUE_REASONING_RUN, ''); + if (trailing.length >= OPAQUE_REASONING_MIN_RUN) { + return { readable, cursor: { tail: '', opaque: true } }; + } + return { readable, cursor: { tail: trailing, opaque: false } }; } From 5da14607f2727c3486c35a3d045df4828909dbed Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 13:27:31 -0400 Subject: [PATCH 3/9] fix: Show readable reasoning around payloads and settle words before counting - the reasoning disclosures, grouped rows, live header and conversation export render and copy the readable text of a THINK part with encoded payloads removed; the encrypted marker stays for parts with nothing readable - reasoning labels count and send only settled readable text: an undecided trailing run never advances minChars/updateChars or reaches the prompt, whatever the configured thresholds, and a short final word settles when the step closes --- .../Chat/Messages/Content/Parts/Reasoning.tsx | 12 +- .../Parts/__tests__/ReasoningCompact.test.tsx | 18 +++ .../Content/__tests__/LiveParity.test.tsx | 15 ++- .../components/Chat/Messages/Content/live.ts | 3 +- .../Conversations/__tests__/format.test.ts | 20 ++++ client/src/hooks/Conversations/format.ts | 5 +- .../agents/reasoningLabels/runtime.spec.ts | 112 ++++++++++++------ .../api/src/agents/reasoningLabels/runtime.ts | 43 +++---- packages/data-provider/src/reasoning.spec.ts | 6 +- 9 files changed, 154 insertions(+), 80 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index b59bd460677..3ee2ff0c0b3 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -2,8 +2,8 @@ import { memo, useMemo, useState, useEffect, useCallback, useRef, useId } from ' import { useAtom } from 'jotai'; import copy from 'copy-to-clipboard'; import { Lightbulb, ChevronDown } from 'lucide-react'; -import { ContentTypes } from 'librechat-data-provider'; import { Button, disclosureChevronVariants } from '@librechat/client'; +import { ContentTypes, stripOpaqueReasoning } from 'librechat-data-provider'; import type { MouseEvent, FocusEvent } from 'react'; import { ThinkingContent, @@ -26,6 +26,11 @@ const stripThinkTags = (reasoning: string): string => .replace(/\s*<\/think>$/, '') .trim(); +/** The readable reasoning to show and copy: wrapper tags and any encoded + * provider payload mixed into the step are removed. */ +const readableReasoning = (reasoning: string): string => + stripThinkTags(stripOpaqueReasoning(reasoning)); + const PEEK_SENTENCES = 4; /** Tail of streaming reasoning, specifically the last few sentences, for the collapsed @@ -177,8 +182,7 @@ const Reasoning = memo((props: ReasoningProps) => { const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded); const { isSubmitting, isLatestMessage, nextType } = useMessage(); - // Strip tags from the reasoning content (modern format) - const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]); + const reasoningText = useMemo(() => readableReasoning(reasoning), [reasoning]); const handleClick = useCallback( (e: MouseEvent) => { @@ -342,7 +346,7 @@ export const ReasoningCompact = memo( * more expensive on top of the visible peek. */ const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded); - const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]); + const reasoningText = useMemo(() => readableReasoning(reasoning), [reasoning]); const handleToggle = useCallback( (e: MouseEvent) => { diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx index ee6228759b9..45b8163af93 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx @@ -116,4 +116,22 @@ describe('ReasoningCompact', () => { expect(screen.getByText('A long stream of reasoning')).toBeInTheDocument(); }); + + it('shows only the readable text of reasoning mixed with an encrypted payload', () => { + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + render( + , + ); + + const body = screen.getByRole('group', { hidden: true }); + expect(body).toHaveTextContent('Checking the manifest.'); + expect(body).toHaveTextContent('Rolling back.'); + expect(body.textContent).not.toContain(encoded.slice(0, 64)); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx index 82d01268485..9b244644c92 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx @@ -1237,7 +1237,9 @@ describe('live activity hardening transitions', () => { expect(screen.getByText('Ran agent').closest('[data-testid="activity-phase-card"]')).toBeNull(); }); - it.each(['x'.repeat(1199), 'Earlier sentence. ' + 'x'.repeat(1199)])( + /** A non-ASCII run: an unbroken ASCII alphanumeric run this long is + * classified as an encoded provider payload and never previewed. */ + it.each(['é'.repeat(1199), 'Earlier sentence. ' + 'é'.repeat(1199)])( 'does not repeatedly announce one thought as its bounded preview window moves', (initial) => { jest.useFakeTimers(); @@ -1255,7 +1257,7 @@ describe('live activity hardening transitions', () => { expect(screen.getByTestId('activity-phase-announcer')).toBeEmptyDOMElement(); /** The finished sentence is the long run itself, clamped to one line; * the sentence after it is still being written. */ - expect(screen.getByRole('button')).toHaveAccessibleName(`${'x'.repeat(255)}…`); + expect(screen.getByRole('button')).toHaveAccessibleName(`${'é'.repeat(255)}…`); }, ); @@ -1739,6 +1741,15 @@ describe('live activity over encrypted reasoning', () => { expect(activity.text).toBe('com_ui_running'); }); + it('previews the readable text of reasoning mixed with an encrypted payload', () => { + const activity = getLiveActivity( + [think(`Comparing both lockfiles. ${encoded}`)], + (key) => key, + [], + ); + expect(activity.text).toBe('Comparing both lockfiles.'); + }); + it('still previews readable reasoning', () => { const activity = getLiveActivity( [think('Comparing both lockfiles. The resolver pins an older range.')], diff --git a/client/src/components/Chat/Messages/Content/live.ts b/client/src/components/Chat/Messages/Content/live.ts index 924423f009c..c28cb33cb75 100644 --- a/client/src/components/Chat/Messages/Content/live.ts +++ b/client/src/components/Chat/Messages/Content/live.ts @@ -2,6 +2,7 @@ import { Constants, ContentTypes, isOpaqueReasoning, + stripOpaqueReasoning, stripToolCallErrorPrefix, } from 'librechat-data-provider'; import type { @@ -317,7 +318,7 @@ function newestLine( /** Encrypted reasoning has no sentence to preview, and a label generated * from it before labels skipped encoded payloads describes the payload. */ const opaque = isOpaqueReasoning(think); - const reasoning = opaque ? '' : think; + const reasoning = stripOpaqueReasoning(think); const label = opaque ? undefined : part.reasoning_label?.trim(); if (preferLabels) { /** Only a generated label will do: the generic thinking line is what diff --git a/client/src/hooks/Conversations/__tests__/format.test.ts b/client/src/hooks/Conversations/__tests__/format.test.ts index 0f66fd034d6..134d69ccec5 100644 --- a/client/src/hooks/Conversations/__tests__/format.test.ts +++ b/client/src/hooks/Conversations/__tests__/format.test.ts @@ -180,6 +180,26 @@ describe('formatMessageContent', () => { ]); }); + it('exports only the readable part of reasoning with encrypted payloads', () => { + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + expect( + formatMessageContent({ + sender: 'Assistant', + content: { type: ContentTypes.THINK, think: `${encoded}` }, + localize, + }), + ).toEqual([]); + expect( + formatMessageContent({ + sender: 'Assistant', + content: { type: ContentTypes.THINK, think: `Check assumptions\n${encoded}` }, + localize, + }), + ).toEqual(['Assistant', 'Thinking:\nCheck assumptions']); + }); + it('concatenates summary chunks without inserted paragraphs', () => { expect( formatMessageContent({ diff --git a/client/src/hooks/Conversations/format.ts b/client/src/hooks/Conversations/format.ts index 0603950c2f1..f4069b1f823 100644 --- a/client/src/hooks/Conversations/format.ts +++ b/client/src/hooks/Conversations/format.ts @@ -1,4 +1,4 @@ -import { ContentTypes, ToolCallTypes } from 'librechat-data-provider'; +import { ContentTypes, ToolCallTypes, stripOpaqueReasoning } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { LocalizeFunction } from '~/common'; @@ -136,7 +136,8 @@ export function formatMessageContent({ } if (content.type === ContentTypes.THINK) { - const reasoning = stripThinkTags(getTextValue(content.think)); + /** Encrypted provider reasoning is not something the reader can use. */ + const reasoning = stripThinkTags(stripOpaqueReasoning(getTextValue(content.think))); if (reasoning.trim().length === 0) { return []; } diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index 9ca3a51f4c6..adad6706a25 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -9,6 +9,8 @@ import type { import type { LooseContentPart } from '~/agents/activityLabels/wiring'; import { createReasoningLabelWiring, synthesizeReasoningLabelGapEvents } from './runtime'; +/** Test deltas end on a separator: the payload filter holds an unfinished + * word until the next non-payload character settles it. */ function reasoningDelta(id: string, think: string) { return { id, delta: { content: { type: ContentTypes.THINK, think } } }; } @@ -127,15 +129,15 @@ describe('reasoning labels', () => { const generate = jest.fn(async () => ({ label: 'Tracing resume ownership' })); const harness = createHarness(generate, { minChars: 10, updateChars: 8, updateIntervalMs: 0 }); await harness.start(); - await harness.append('123456789'); + await harness.append('12345678 '); expect(generate).not.toHaveBeenCalled(); - await harness.append('0'); + await harness.append('.'); await harness.settle(); expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith( - expect.objectContaining({ revision: 1, status: 'streaming', visibleReasoning: '1234567890' }), + expect.objectContaining({ revision: 1, status: 'streaming', visibleReasoning: '12345678 .' }), ); expect(harness.parts).toHaveLength(1); expect(harness.parts[0]).toMatchObject({ @@ -168,16 +170,16 @@ describe('reasoning labels', () => { await harness.settle(); expect(generate).not.toHaveBeenCalled(); - await harness.append('1234', 'reasoning-new'); + await harness.append('123 ', 'reasoning-new'); await harness.settle(); expect(generate).not.toHaveBeenCalled(); - await harness.append('5', 'reasoning-new'); + await harness.append('.', 'reasoning-new'); await harness.settle(); expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith( - expect.objectContaining({ visibleReasoning: '12345', revision: 2 }), + expect.objectContaining({ visibleReasoning: '123 .', revision: 2 }), ); }); @@ -195,9 +197,9 @@ describe('reasoning labels', () => { updateIntervalMs: 3_000, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); - await harness.append('6789'); + await harness.append('678 '); expect(generate).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(3_000); @@ -224,9 +226,9 @@ describe('reasoning labels', () => { updateIntervalMs: 3_000, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); - await harness.append('6789'); + await harness.append('678 '); expect(generate).toHaveBeenCalledTimes(1); await jest.advanceTimersByTimeAsync(3_000); @@ -245,7 +247,7 @@ describe('reasoning labels', () => { updateIntervalMs: 0, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); expect(collectUsage).toHaveBeenCalledTimes(1); @@ -264,7 +266,7 @@ describe('reasoning labels', () => { }, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); expect(collectUsage).toHaveBeenCalledTimes(1); @@ -302,7 +304,7 @@ describe('reasoning labels', () => { }, ); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await usageStarted; await emitStarted; await Promise.resolve(); @@ -354,7 +356,7 @@ describe('reasoning labels', () => { }, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await streamingEmit; await harness.append(' word'.repeat(24)); await harness.close(); @@ -377,13 +379,13 @@ describe('reasoning labels', () => { const generate = jest.fn(async () => ({ label: 'Tracing the streaming path' })); const harness = createHarness(generate, { minChars: 5, updateChars: 100, updateIntervalMs: 0 }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); - await harness.append(' later'); + await harness.append(' later.'); expect(harness.parts[0]).toMatchObject({ - think: '12345 later', + think: '1234 later.', reasoning_label: 'Tracing the streaming path', reasoning_label_step_id: 'reasoning-1', reasoning_label_revision: 1, @@ -410,15 +412,15 @@ describe('reasoning labels', () => { }, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await emitStarted; - await harness.append(' later'); + await harness.append(' later.'); releaseEmit?.(); await harness.settle(); expect(harness.parts[0]).toMatchObject({ - think: '12345 later', + think: '1234 later.', reasoning_label: 'Tracing the concurrent stream', reasoning_label_revision: 1, }); @@ -443,7 +445,7 @@ describe('reasoning labels', () => { }, }); await harness.start('reasoning-1', 0); - await harness.append('12345', 'reasoning-1'); + await harness.append('1234 ', 'reasoning-1'); await emitStarted; await harness.start('reasoning-2', 0); @@ -477,7 +479,7 @@ describe('reasoning labels', () => { }, }); await harness.start('reasoning-1', 0); - await harness.append('12345', 'reasoning-1'); + await harness.append('1234 ', 'reasoning-1'); await emitStarted; await harness.start('reasoning-2', 0); @@ -504,8 +506,8 @@ describe('reasoning labels', () => { .mockResolvedValueOnce({ label: 'Resolved the final direction' }); const harness = createHarness(generate, { minChars: 5, updateChars: 4, updateIntervalMs: 0 }); await harness.start(); - await harness.append('12345'); - await harness.append('6789'); + await harness.append('1234 '); + await harness.append('678 '); await harness.close(); expect(generate).toHaveBeenCalledTimes(1); @@ -517,7 +519,7 @@ describe('reasoning labels', () => { revision: 2, status: 'complete', previousLabel: 'Investigating the direction', - visibleReasoning: '123456789', + visibleReasoning: '1234 678', }); expect(harness.events[harness.events.length - 1]).toMatchObject({ revision: 2, @@ -530,9 +532,9 @@ describe('reasoning labels', () => { const generate = jest.fn(async () => ({ label: 'Inspecting the stream' })); const harness = createHarness(generate, { minChars: 5, updateChars: 400, updateIntervalMs: 0 }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); - await harness.append('small tail'); + await harness.append('small tail.'); await harness.close(); await harness.settle(); @@ -554,7 +556,7 @@ describe('reasoning labels', () => { updateIntervalMs: 3_000, }); await harness.start(); - await harness.append('12345'); + await harness.append('1234 '); await harness.settle(); await harness.append(' word'.repeat(24)); await harness.close(); @@ -577,7 +579,7 @@ describe('reasoning labels', () => { last_agent_id: 'final-agent', langgraph_node: 'intermediate-agent', }); - await harness.append('12345'); + await harness.append('1234 '); await harness.close(); await harness.settle(); @@ -594,9 +596,9 @@ describe('reasoning labels', () => { maxPerRun: 2, }); await firstSegment.start('reasoning-1', 0); - await firstSegment.append('12345'); + await firstSegment.append('1234 '); await firstSegment.settle(); - await firstSegment.append('6789'); + await firstSegment.append('678 '); await firstSegment.settle(); expect(generate).toHaveBeenCalledTimes(2); @@ -615,7 +617,7 @@ describe('reasoning labels', () => { initialParts: firstSegment.parts, }); await resumed.start('reasoning-2', 1); - await resumed.append('abcde', 'reasoning-2'); + await resumed.append('abcd ', 'reasoning-2'); await resumed.close('reasoning-2'); await resumed.settle(); @@ -641,7 +643,7 @@ describe('reasoning labels', () => { }); await resumed.start('reasoning-after-resume', initialParts.length); - await resumed.append('abcde', 'reasoning-after-resume'); + await resumed.append('abcd ', 'reasoning-after-resume'); await resumed.settle(); expect(generate).toHaveBeenCalledTimes(1); @@ -667,7 +669,7 @@ describe('reasoning labels', () => { ], }); await harness.start('reasoning-new', 1); - await harness.append('abcde', 'reasoning-new'); + await harness.append('abcd ', 'reasoning-new'); await harness.settle(); expect(generate).toHaveBeenCalledTimes(1); @@ -683,9 +685,9 @@ describe('reasoning labels', () => { maxPerRun: 2, }); await firstSegment.start('reasoning-1', 0); - await firstSegment.append('12345', 'reasoning-1'); + await firstSegment.append('1234 ', 'reasoning-1'); await firstSegment.settle(); - await firstSegment.append('6789', 'reasoning-1'); + await firstSegment.append('678 ', 'reasoning-1'); await firstSegment.settle(); await firstSegment.start('reasoning-2', 0); @@ -705,7 +707,7 @@ describe('reasoning labels', () => { initialParts: firstSegment.parts.map((part) => ({ ...part })), }); await resumed.start('reasoning-3', 1); - await resumed.append('vwxyz', 'reasoning-3'); + await resumed.append('vwxy ', 'reasoning-3'); await resumed.close('reasoning-3'); await resumed.settle(); @@ -732,12 +734,12 @@ describe('reasoning labels', () => { await harness.start('reasoning-1', 0); expect(generate).not.toHaveBeenCalled(); - await harness.append('89', 'reasoning-1'); + await harness.append('8 ', 'reasoning-1'); await harness.settle(); expect(generate).toHaveBeenCalledTimes(1); expect(generate).toHaveBeenCalledWith( - expect.objectContaining({ visibleReasoning: '123456789', revision: 2 }), + expect.objectContaining({ visibleReasoning: '12345678', revision: 2 }), ); }); @@ -936,6 +938,38 @@ describe('reasoning labels for encoded reasoning', () => { }); }); + it('never labels the undecided start of a payload under low thresholds', async () => { + const generate = jest.fn(async () => ({ label: 'Inspecting an encoded data snapshot' })); + const harness = createHarness(generate, { minChars: 5, updateChars: 4, updateIntervalMs: 0 }); + const payload = encodedReasoning(); + await harness.start(); + for (let offset = 0; offset < payload.length; offset += 10) { + await harness.append(payload.slice(offset, offset + 10)); + await harness.settle(); + } + await harness.close(); + await harness.settle(); + + expect(generate).not.toHaveBeenCalled(); + expect(harness.attemptEvents).toEqual([]); + }); + + it('settles a final unpunctuated word when the step closes', async () => { + const generate = jest.fn(async () => ({ label: 'Checking the result' })); + const harness = createHarness(generate, { minChars: 5, updateChars: 4, updateIntervalMs: 0 }); + await harness.start(); + await harness.append('Checking'); + await harness.settle(); + expect(generate).not.toHaveBeenCalled(); + + await harness.close(); + await harness.settle(); + + expect(generate).toHaveBeenCalledWith( + expect.objectContaining({ status: 'complete', visibleReasoning: 'Checking' }), + ); + }); + it('labels mixed reasoning from its readable text only', async () => { const generate = jest.fn(async () => ({ label: 'Comparing retry backoff strategies' })); const harness = createHarness(generate, { ...config, updateChars: 1_000 }); diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index 8ff79f6edc4..d176454e1a0 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -85,9 +85,10 @@ interface ReasoningStepState { index?: number; /** Readable reasoning only; encoded provider payloads never enter it. */ text: string; - /** Settled readable characters, so encoded payloads never advance the gates. */ + /** Settled readable characters only, so encoded payloads never advance the gates. */ totalChars: number; - /** Trailing run not yet classified as a word or a payload (under 128 chars). */ + /** Trailing run not yet classified as a word or a payload; it joins `text` + * once a later character settles it. */ cursor: OpaqueReasoningCursor; label?: string; labelStatus?: ReasoningLabelStatus; @@ -137,17 +138,6 @@ function deltaText(data: unknown): string { .join(''); } -/** The undecided tail when it is word-sized. A longer tail is more likely the - * start of a payload split across deltas, so it waits until it settles. */ -function pendingWord(state: Pick): string { - const { tail } = state.cursor; - return tail.length <= MAX_PENDING_WORD_CHARS ? tail : ''; -} - -function visibleChars(state: Pick): number { - return state.totalChars + pendingWord(state).length; -} - function normalizeLabel(value: string | undefined): string { const firstLine = value?.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ''; const normalized = firstLine @@ -357,7 +347,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason }; const shouldGenerate = (state: ReasoningStepState, final: boolean): boolean => { - const length = visibleChars(state); + const length = state.totalChars; if (!state.label) { if (length < minChars) { return false; @@ -413,7 +403,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } clearTimer(state); - const visibleReasoning = `${state.text}${pendingWord(state)}`.trim(); + const visibleReasoning = state.text.trim(); if (!visibleReasoning) { if (state.pendingFinal) { state.pendingFinal = false; @@ -423,7 +413,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } const status: ReasoningLabelStatus = state.pendingFinal ? 'complete' : 'streaming'; state.pendingFinal = false; - state.submittedChars = visibleChars(state); + state.submittedChars = state.totalChars; state.lastSubmittedAt = now(); generated += 1; state.attempts += 1; @@ -509,7 +499,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } })().finally(() => { state.inFlight = undefined; - if (state.closed || visibleChars(state) - state.submittedChars >= updateChars) { + if (state.closed || state.totalChars - state.submittedChars >= updateChars) { schedule(state, state.closed); } }); @@ -524,10 +514,12 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason } state.closed = true; clearTimer(state); - const word = pendingWord(state); - if (word) { - state.text = appendBoundedReasoning(state.text, word); - state.totalChars += word.length; + /** The stream is over, so a short undecided tail was a final word; a + * longer one is the unfinished start of a payload. */ + const { tail } = state.cursor; + if (tail.length > 0 && tail.length <= MAX_PENDING_WORD_CHARS) { + state.text = appendBoundedReasoning(state.text, tail); + state.totalChars += tail.length; } state.cursor = OPAQUE_REASONING_START; schedule(state, true); @@ -645,16 +637,13 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason : {}), }); } - const previousTail = state.cursor.tail; const { readable: text, cursor } = advanceOpaqueReasoning(state.cursor, rawText); state.cursor = cursor; - if (!text && cursor.tail === previousTail) { + if (!text) { return; } - if (text) { - state.text = appendBoundedReasoning(state.text, text); - state.totalChars += text.length; - } + state.text = appendBoundedReasoning(state.text, text); + state.totalChars += text.length; schedule(state); }; diff --git a/packages/data-provider/src/reasoning.spec.ts b/packages/data-provider/src/reasoning.spec.ts index 24ddc301d41..3e741348998 100644 --- a/packages/data-provider/src/reasoning.spec.ts +++ b/packages/data-provider/src/reasoning.spec.ts @@ -48,11 +48,7 @@ describe('isOpaqueReasoning', () => { it('classifies a payload inside the persisted think wrapper as opaque', () => { expect(isOpaqueReasoning(`${bedrockRedacted}`)).toBe(true); - expect( - isOpaqueReasoning(` -${anthropicRedacted} -`), - ).toBe(true); + expect(isOpaqueReasoning(`\n${anthropicRedacted}\n`)).toBe(true); expect(isOpaqueReasoning('Checking the manifest.')).toBe(false); expect(isOpaqueReasoning('')).toBe(false); }); From f53984b1b16391ff827ae925a1c12f2aedb39921 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 13:40:34 -0400 Subject: [PATCH 4/9] fix: Classify reasoning before every bound and across resume - once a run is classified as a payload, it continues through the next non-payload character even when that arrives in the same delta as readable text, so no tail fragment of the payload is released as readable - add sliceReasoningTail: the activity-phase rolling reasoning window (and its snapshot/restore bounds) never starts inside an unbroken run, so a payload cut by the window cannot leave a fragment too short to classify; the window stays a substring of the raw THINK text, so phase anchoring is unchanged - a resumed reasoning step replays its persisted text through the stream filter and keeps the resulting cursor, so a payload prefix that was still undecided at the snapshot is dropped once the continuation classifies it --- .../src/agents/activityPhases/runtime.spec.ts | 44 +++++++++++++++++++ .../api/src/agents/activityPhases/runtime.ts | 22 +++++++--- .../agents/reasoningLabels/runtime.spec.ts | 37 ++++++++++++++-- .../api/src/agents/reasoningLabels/runtime.ts | 15 +++++-- packages/data-provider/src/reasoning.spec.ts | 29 +++++++++++- packages/data-provider/src/reasoning.ts | 40 ++++++++++++----- 6 files changed, 161 insertions(+), 26 deletions(-) diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index 20bfaf61ce1..5e8b793e606 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -901,6 +901,50 @@ describe('createActivityPhaseWiring', () => { expect(parts).toHaveLength(1); }); + it('never starts the rolling reasoning excerpt inside an encrypted payload', () => { + const encoded = Buffer.from( + Array.from({ length: 1_050 }, (_, i) => (i * 37 + 3) % 256), + ).toString('base64'); + const readable = 'Checking the manifest before rolling back the deploy. ' + .repeat(11) + .slice(0, 590); + const wiring = createActivityPhaseWiring({ + getContentParts: () => [], + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'unused' })), + }); + const handlers = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + [GraphEvents.ON_REASONING_DELTA]: { handle: jest.fn() }, + }); + handlers?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'reasoning-step', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'think' }, + }, + } as never, + undefined, + undefined, + ); + for (const think of [encoded, `\n${readable}`]) { + handlers?.[GraphEvents.ON_REASONING_DELTA]?.handle( + GraphEvents.ON_REASONING_DELTA, + { id: 'reasoning-step', delta: { content: { type: ContentTypes.THINK, think } } } as never, + undefined, + undefined, + ); + } + + expect(wiring.snapshot().pendingReasoning).toEqual([ + expect.objectContaining({ key: 'root', text: `\n${readable}` }), + ]); + }); + it('keeps reasoning attached to a tool batch across commentary', async () => { const parts: LooseContentPart[] = []; const generatePhase = jest.fn(async () => ({ label: 'unused' })); diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index c3c454cbc14..f0b7d67463d 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -1,5 +1,10 @@ import { GraphEvents } from '@librechat/agents'; -import { ContentTypes, StepTypes, stripOpaqueReasoning } from 'librechat-data-provider'; +import { + StepTypes, + ContentTypes, + sliceReasoningTail, + stripOpaqueReasoning, +} from 'librechat-data-provider'; import type { EventHandler, HookCallback, HookInputByEvent } from '@librechat/agents'; import type { LooseContentPart } from '~/agents/activityLabels/wiring'; import { stringifyActivityEvidence } from '~/agents/activityLabels/runtime'; @@ -858,7 +863,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity .map((entry) => (typeof entry === 'string' ? { text: entry } : { ...entry })); const pendingReasoning = new Map( (initialSnapshot?.pendingReasoning ?? []).map(({ key, text, agentId, startIndex }) => { - const boundedText = text.slice(-MAX_EXCERPT_CHARS); + const boundedText = sliceReasoningTail(text, MAX_EXCERPT_CHARS); const needle = boundedText.trim().slice(0, REASONING_ANCHOR_CHARS); const rebasedStartIndex = needle ? findReasoningStart(content, boundedText, startIndex) : -1; /** The anchor is a prefix, so two lanes can share it. Binding to the @@ -1013,7 +1018,9 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity })), }), ...(activity.thinkingExcerpts != null && { - thinkingExcerpts: activity.thinkingExcerpts.map((text) => text.slice(-MAX_EXCERPT_CHARS)), + thinkingExcerpts: activity.thinkingExcerpts.map((text) => + sliceReasoningTail(text, MAX_EXCERPT_CHARS), + ), }), })), assistantContext: assistantContext @@ -1023,7 +1030,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity ), pendingReasoning: [...pendingReasoning].map(([key, reasoning]) => ({ key, - text: reasoning.text.slice(-MAX_EXCERPT_CHARS), + text: sliceReasoningTail(reasoning.text, MAX_EXCERPT_CHARS), ...(reasoning.agentId != null && { agentId: reasoning.agentId }), ...(reasoning.startIndex != null && { startIndex: reasoning.startIndex }), })), @@ -1598,8 +1605,11 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity const reasoningKey = id ? reasoningStepKeys.get(id) : undefined; const reasoning = reasoningKey ? pendingReasoning.get(reasoningKey) : undefined; if (reasoning != null) { - reasoning.text = `${reasoning.text}${deltaText(data, 'think')}`.slice( - -MAX_EXCERPT_CHARS, + /** Bounded without starting mid-run, so an encrypted payload cut by + * the window cannot leave an unclassifiable fragment in the excerpt. */ + reasoning.text = sliceReasoningTail( + `${reasoning.text}${deltaText(data, 'think')}`, + MAX_EXCERPT_CHARS, ); } return reasoningHandler.handle(event, data, metadata, graph); diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index adad6706a25..fa4ea1fe726 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -978,7 +978,7 @@ describe('reasoning labels for encoded reasoning', () => { await harness.append('Comparing the retry policy against the queue backoff. '); await harness.settle(); await harness.append(payload); - await harness.append('Exponential backoff fits the burst pattern, so the queue keeps it. '); + await harness.append(' Exponential backoff fits the burst pattern, so the queue keeps it. '); await harness.append( 'Jitter spreads the retries across the window to avoid a thundering herd.', ); @@ -994,7 +994,7 @@ describe('reasoning labels for encoded reasoning', () => { expect(generate).toHaveBeenLastCalledWith( expect.objectContaining({ status: 'complete', - visibleReasoning: expect.stringContaining('backoff. Exponential backoff fits'), + visibleReasoning: expect.stringContaining('backoff. Exponential backoff fits'), }), ); expect(harness.parts[0]?.reasoning_label).toBe('Comparing retry backoff strategies'); @@ -1034,7 +1034,7 @@ describe('reasoning labels for encoded reasoning', () => { await harness.settle(); expect(generate).not.toHaveBeenCalled(); - await harness.append('Checking the tool result.'); + await harness.append(' Checking the tool result.'); await harness.close(); await harness.settle(); @@ -1043,4 +1043,35 @@ describe('reasoning labels for encoded reasoning', () => { expect.objectContaining({ visibleReasoning: 'Checking the tool result.' }), ); }); + it('drops a payload prefix that was still undecided when a resumed step continues', async () => { + const generate = jest.fn(async () => ({ label: 'Rolling back the deploy' })); + const payload = encodedReasoning(); + const harness = createHarness(generate, { + minChars: 30, + updateChars: 8, + updateIntervalMs: 0, + preservePartOnStart: true, + initialParts: [ + { + type: ContentTypes.THINK, + think: `Checking. ${payload.slice(0, 100)}`, + reasoning_label_step_id: 'reasoning-1', + reasoning_label_attempts: 0, + }, + ], + }); + await harness.start(); + await harness.append(payload.slice(100)); + await harness.append(' Rolling back the deploy now.'); + await harness.close(); + await harness.settle(); + + expect(generate).toHaveBeenCalled(); + for (const [payloadArg] of generate.mock.calls as unknown as Array< + [{ visibleReasoning: string }] + >) { + expect(payloadArg.visibleReasoning).not.toContain(payload.slice(0, 32)); + expect(payloadArg.visibleReasoning).toContain('Rolling back the deploy now.'); + } + }); }); diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index d176454e1a0..fdcdd503caf 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -2,7 +2,6 @@ import { GraphEvents } from '@librechat/agents'; import { StepTypes, ContentTypes, - stripOpaqueReasoning, advanceOpaqueReasoning, OPAQUE_REASONING_START, } from 'librechat-data-provider'; @@ -113,6 +112,7 @@ const MAX_TRACKED_REASONING_CHARS = 8_000; * completion rewrite; a smaller tail only upgrades the current status. */ const FINAL_UPDATE_CHAR_LIMIT = 120; const OUTPUT_CHAR_LIMIT = 120; +const THINK_TAGS = /<\/?think>/g; const MAX_PENDING_WORD_CHARS = 32; const REQUEST_TIMEOUT_MS = 12_000; @@ -563,7 +563,16 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason ownsExistingStep && typeof part.reasoning_label === 'string' ? part.reasoning_label : undefined; - const existingText = ownsExistingStep ? stripOpaqueReasoning(textValue(part?.think)) : ''; + /** Replayed through the stream filter so a payload that was still + * undecided at the snapshot keeps its cursor and is dropped once the + * continuation classifies it, instead of settling as readable text. */ + const resumed = ownsExistingStep + ? advanceOpaqueReasoning( + OPAQUE_REASONING_START, + textValue(part?.think).replace(THINK_TAGS, ''), + ) + : { readable: '', cursor: OPAQUE_REASONING_START }; + const existingText = resumed.readable; const persistedSubmittedChars = ownsExistingStep && typeof part.reasoning_label_submitted_chars === 'number' ? Math.min(part.reasoning_label_submitted_chars, existingText.trim().length) @@ -582,7 +591,7 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason ...(index != null && { index }), text: appendBoundedReasoning('', existingText), totalChars: existingText.trim().length, - cursor: OPAQUE_REASONING_START, + cursor: resumed.cursor, ...(existingLabel != null && { label: existingLabel }), ...(existingLabel != null && { labelStatus: part?.reasoning_label_status === 'complete' ? 'complete' : 'streaming', diff --git a/packages/data-provider/src/reasoning.spec.ts b/packages/data-provider/src/reasoning.spec.ts index 3e741348998..a544ce6a255 100644 --- a/packages/data-provider/src/reasoning.spec.ts +++ b/packages/data-provider/src/reasoning.spec.ts @@ -2,6 +2,7 @@ import type { OpaqueReasoningCursor } from './reasoning'; import { isOpaqueReasoning, stripOpaqueReasoning, + sliceReasoningTail, advanceOpaqueReasoning, OPAQUE_REASONING_START, } from './reasoning'; @@ -119,9 +120,33 @@ describe('advanceOpaqueReasoning', () => { const result = stream([ 'Checking the retry policy. ', ...chunks(bedrockRedacted, 100), - 'Backoff fits the burst pattern.', + ' Backoff fits the burst pattern.', ]); - expect(result.readable).toBe('Checking the retry policy. Backoff fits the burst pattern.'); + expect(result.readable).toBe('Checking the retry policy. Backoff fits the burst pattern.'); expect(result.cursor).toEqual({ tail: '', opaque: false }); }); + + it('never releases the end of a payload that shares a delta with readable text', () => { + const payload = chunks(bedrockRedacted, 100); + const last = payload.pop() ?? ''; + const result = stream(['Checking. ', ...payload, `${last}. Rolling back.`]); + expect(result.readable).toBe('Checking. . Rolling back.'); + }); +}); + +describe('sliceReasoningTail', () => { + it('keeps short reasoning whole', () => { + expect(sliceReasoningTail('Checking the manifest.', 600)).toBe('Checking the manifest.'); + }); + + it('starts after a payload the window would cut', () => { + const text = `${bedrockRedacted}\nRolling back the deploy.`; + const tail = sliceReasoningTail(text, 100); + expect(tail).toBe('\nRolling back the deploy.'); + expect(isOpaqueReasoning(tail)).toBe(false); + }); + + it('cuts at the window boundary when it falls between words', () => { + expect(sliceReasoningTail('alpha beta gamma', 6)).toBe(' gamma'); + }); }); diff --git a/packages/data-provider/src/reasoning.ts b/packages/data-provider/src/reasoning.ts index a0f504a8af5..6d64678821b 100644 --- a/packages/data-provider/src/reasoning.ts +++ b/packages/data-provider/src/reasoning.ts @@ -51,13 +51,12 @@ export interface OpaqueReasoningCursor { export const OPAQUE_REASONING_START: OpaqueReasoningCursor = { tail: '', opaque: false }; -function isPayloadOnly(text: string): boolean { - for (let i = 0; i < text.length; i += 1) { - if (!isPayloadChar(text.charCodeAt(i))) { - return false; - } +function payloadRunEnd(text: string, from: number): number { + let end = from; + while (end < text.length && isPayloadChar(text.charCodeAt(end))) { + end += 1; } - return true; + return end; } /** @@ -65,19 +64,20 @@ function isPayloadOnly(text: string): boolean { * split across deltas shorter than the classification threshold, so a trailing * run is held in the cursor until a non-payload character settles it; readable * words are held only until the next space or punctuation. Once a run is known - * to be a payload, deltas made only of payload characters continue it, and the - * first delta with any other character starts fresh, since providers stream a - * payload and the reasoning after it as separate blocks. Returns the settled - * readable text, which callers can count and accumulate directly. + * to be a payload, it continues through the next non-payload character, even + * across deltas, so no fragment of it is ever released as readable text. + * Returns the settled readable text, which callers can count and accumulate + * directly. */ export function advanceOpaqueReasoning( cursor: OpaqueReasoningCursor, delta: string, ): { readable: string; cursor: OpaqueReasoningCursor } { - if (cursor.opaque && isPayloadOnly(delta)) { + const start = cursor.opaque ? payloadRunEnd(delta, 0) : 0; + if (start === delta.length && cursor.opaque) { return { readable: '', cursor }; } - const combined = `${cursor.opaque ? '' : cursor.tail}${delta}`; + const combined = `${cursor.opaque ? '' : cursor.tail}${delta.slice(start)}`; let end = combined.length; while (end > 0 && isPayloadChar(combined.charCodeAt(end - 1))) { end -= 1; @@ -89,3 +89,19 @@ export function advanceOpaqueReasoning( } return { readable, cursor: { tail: trailing, opaque: false } }; } + +/** + * The last `maxChars` of reasoning for a rolling excerpt. A cut inside an + * unbroken run would leave a fragment too short to classify, so a window that + * starts mid-run skips to the end of that run. + */ +export function sliceReasoningTail(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + const start = text.length - maxChars; + if (!isPayloadChar(text.charCodeAt(start - 1))) { + return text.slice(start); + } + return text.slice(payloadRunEnd(text, start)); +} From 79a194d88a1db6c2577d024930f31e3b1a330e0b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 14:00:17 -0400 Subject: [PATCH 5/9] fix: Classify phase evidence while it streams and mask payloads while pending - activity phases classify each reasoning delta with the stream cursor and keep a readable rolling window beside the raw anchor window; tracked activities carry readableExcerpts as prompt evidence, so a window cut into a payload can never forward a fragment (older snapshots fall back to stripping); the cursor-free sliceReasoningTail is removed - add readableReasoning / flushOpaqueReasoning: while reasoning streams, the disclosures and live header hold back a trailing run that could still grow into a payload instead of showing its first characters - the message editor offers no field for payload-only reasoning and shows only the readable text of mixed reasoning; an unchanged part is never written, so its stored payload stays intact --- .../Messages/Content/EditContentParts.tsx | 4 +- .../Chat/Messages/Content/Parts/Reasoning.tsx | 25 +-- .../Parts/__tests__/ReasoningCompact.test.tsx | 18 +++ .../__tests__/EditContentParts.spec.tsx | 27 ++++ .../Content/__tests__/LiveParity.test.tsx | 9 ++ .../Content/__tests__/editableParts.spec.ts | 36 ++++- .../Chat/Messages/Content/editableParts.ts | 20 ++- .../components/Chat/Messages/Content/live.ts | 5 +- .../src/agents/activityPhases/runtime.spec.ts | 37 ++++- .../api/src/agents/activityPhases/runtime.ts | 153 ++++++++++++------ .../api/src/agents/reasoningLabels/runtime.ts | 10 +- packages/data-provider/src/reasoning.spec.ts | 27 ++-- packages/data-provider/src/reasoning.ts | 30 ++-- 13 files changed, 298 insertions(+), 103 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/EditContentParts.tsx b/client/src/components/Chat/Messages/Content/EditContentParts.tsx index 0f25001a0ee..79769efcaeb 100644 --- a/client/src/components/Chat/Messages/Content/EditContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/EditContentParts.tsx @@ -9,8 +9,8 @@ import { } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { ReactNode } from 'react'; +import { getEditableText, isEditablePart, withPartText } from './editableParts'; import { useMessagesConversation, useMessagesOperations } from '~/Providers'; -import { getPartText, isEditablePart, withPartText } from './editableParts'; import { findRerunParent } from './rerunParent'; import { useGetAddedConvo } from '~/hooks/Chat'; import { useLocalize } from '~/hooks'; @@ -65,7 +65,7 @@ export default function EditContentParts({ if (!part || !isEditablePart(part)) { return; } - const original = getPartText(part); + const original = getEditableText(part); if (original == null) { return; } diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 3ee2ff0c0b3..c81dbc89b3f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -3,7 +3,7 @@ import { useAtom } from 'jotai'; import copy from 'copy-to-clipboard'; import { Lightbulb, ChevronDown } from 'lucide-react'; import { Button, disclosureChevronVariants } from '@librechat/client'; -import { ContentTypes, stripOpaqueReasoning } from 'librechat-data-provider'; +import { ContentTypes, readableReasoning } from 'librechat-data-provider'; import type { MouseEvent, FocusEvent } from 'react'; import { ThinkingContent, @@ -26,10 +26,11 @@ const stripThinkTags = (reasoning: string): string => .replace(/\s*<\/think>$/, '') .trim(); -/** The readable reasoning to show and copy: wrapper tags and any encoded - * provider payload mixed into the step are removed. */ -const readableReasoning = (reasoning: string): string => - stripThinkTags(stripOpaqueReasoning(reasoning)); +/** The reasoning to show and copy: wrapper tags and encoded provider payloads + * are removed, and while it streams, a trailing run that could still grow + * into a payload is held back until it is classified. */ +const displayReasoning = (reasoning: string, streaming: boolean): string => + stripThinkTags(readableReasoning(reasoning, streaming)); const PEEK_SENTENCES = 4; @@ -181,8 +182,13 @@ const Reasoning = memo((props: ReasoningProps) => { const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded); const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded); const { isSubmitting, isLatestMessage, nextType } = useMessage(); + const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + const streaming = effectiveIsSubmitting === true && isLast; - const reasoningText = useMemo(() => readableReasoning(reasoning), [reasoning]); + const reasoningText = useMemo( + () => displayReasoning(reasoning, streaming), + [reasoning, streaming], + ); const handleClick = useCallback( (e: MouseEvent) => { @@ -215,8 +221,6 @@ const Reasoning = memo((props: ReasoningProps) => { } }, []); - const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; - const label = useMemo(() => { const generated = reasoningLabel?.trim(); if (generated) { @@ -346,7 +350,10 @@ export const ReasoningCompact = memo( * more expensive on top of the visible peek. */ const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(isExpanded); - const reasoningText = useMemo(() => readableReasoning(reasoning), [reasoning]); + const reasoningText = useMemo( + () => displayReasoning(reasoning, isStreaming), + [reasoning, isStreaming], + ); const handleToggle = useCallback( (e: MouseEvent) => { diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx index 45b8163af93..bb9f66d41b6 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ReasoningCompact.test.tsx @@ -134,4 +134,22 @@ describe('ReasoningCompact', () => { expect(body).toHaveTextContent('Rolling back.'); expect(body.textContent).not.toContain(encoded.slice(0, 64)); }); + + it('holds back a streaming run until it is classified as a word or a payload', () => { + const prefix = Buffer.from(Array.from({ length: 75 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + render( + , + ); + + const body = screen.getByRole('group', { hidden: true }); + expect(body).toHaveTextContent('Checking the manifest.'); + expect(body.textContent).not.toContain(prefix.slice(0, 32)); + }); }); diff --git a/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx b/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx index 264066978ab..eeafd20f618 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx @@ -239,6 +239,33 @@ describe('EditContentParts', () => { ]); }); + it('edits mixed reasoning without showing its encrypted payload', () => { + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + const mixedContent = [ + { type: ContentTypes.THINK, think: `Checking the manifest.\n${encoded}` }, + ] as TMessageContentParts[]; + message.content = mixedContent; + + render( + null} + />, + ); + + const editor = screen.getByRole('textbox') as HTMLTextAreaElement; + expect(editor.value).toBe('Checking the manifest.\n'); + /** Unchanged, so nothing is written and the stored payload stays intact. */ + expect(screen.getByRole('button', { name: 'com_ui_save' })).toBeDisabled(); + }); + it('reruns an assistant response with the edited content value', () => { const enterEdit = jest.fn(); render( diff --git a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx index 9b244644c92..28cc3a7ea1a 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/LiveParity.test.tsx @@ -1750,6 +1750,15 @@ describe('live activity over encrypted reasoning', () => { expect(activity.text).toBe('Comparing both lockfiles.'); }); + it('never previews the undecided start of a streaming payload', () => { + const activity = getLiveActivity( + [think(`Comparing both lockfiles. ${encoded.slice(0, 100)}`)], + (key) => key, + [], + ); + expect(activity.text).toBe('Comparing both lockfiles.'); + }); + it('still previews readable reasoning', () => { const activity = getLiveActivity( [think('Comparing both lockfiles. The resolver pins an older range.')], diff --git a/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts b/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts index ee9686e4b0d..93d1e636c4e 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts +++ b/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts @@ -1,6 +1,6 @@ import { ContentTypes } from 'librechat-data-provider'; -import type { TMessage } from 'librechat-data-provider'; -import { hasEditablePart } from '../editableParts'; +import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; +import { getEditableText, hasEditablePart, isEditablePart } from '../editableParts'; const artifactText = ':::artifact{identifier="demo" type="text/html" title="Demo"}\n
\n:::'; @@ -52,3 +52,35 @@ describe('hasEditablePart', () => { expect(hasEditablePart({ content } as TMessage)).toBe(false); }); }); + +describe('encrypted reasoning in the editor', () => { + /** Bedrock `redactedContent`, base64-encoded into THINK text by the SDK. */ + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + + it('offers no field for reasoning that is only an encrypted payload', () => { + expect( + isEditablePart({ type: ContentTypes.THINK, think: encoded } as TMessageContentParts), + ).toBe(false); + expect( + hasEditablePart({ + content: [{ type: ContentTypes.THINK, think: `${encoded}` }], + } as TMessage), + ).toBe(false); + }); + + it('offers only the readable text of mixed reasoning', () => { + const part = { + type: ContentTypes.THINK, + think: `Checking the manifest. ${encoded}`, + } as TMessageContentParts; + expect(isEditablePart(part)).toBe(true); + expect(getEditableText(part)).toBe('Checking the manifest. '); + }); + + it('leaves text parts untouched', () => { + const part = { type: ContentTypes.TEXT, text: `Token: ${encoded}` } as TMessageContentParts; + expect(getEditableText(part)).toBe(`Token: ${encoded}`); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/editableParts.ts b/client/src/components/Chat/Messages/Content/editableParts.ts index 6e54913ba12..de470ec3119 100644 --- a/client/src/components/Chat/Messages/Content/editableParts.ts +++ b/client/src/components/Chat/Messages/Content/editableParts.ts @@ -1,4 +1,4 @@ -import { ContentTypes } from 'librechat-data-provider'; +import { ContentTypes, isOpaqueReasoning, stripOpaqueReasoning } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts, TextData } from 'librechat-data-provider'; import { splitMarkdownIntoBlocks } from './splitMarkdown'; @@ -21,6 +21,16 @@ export const getPartText = (part: TMessageContentParts): string | undefined => { return typeof value === 'string' ? value : value?.value; }; +/** + * The text the editor offers for a part. Encoded provider reasoning is not + * prose anyone can edit, so a THINK part offers only its readable text; a part + * left unchanged is never written back, which keeps the stored payload intact. + */ +export const getEditableText = (part: TMessageContentParts): string | undefined => { + const text = getPartText(part); + return part.type === ContentTypes.THINK && text != null ? stripOpaqueReasoning(text) : text; +}; + export const withPartText = (part: TMessageContentParts, text: string): string | TextData => { const value = getPartValue(part); return value != null && typeof value === 'object' ? { ...value, value: text } : text; @@ -50,7 +60,13 @@ export const isEditablePart = (part?: TMessageContentParts | null): boolean => { return false; } const text = getPartText(part); - return text != null && !containsArtifact(text); + if (text == null) { + return false; + } + if (part.type === ContentTypes.THINK && isOpaqueReasoning(text)) { + return false; + } + return !containsArtifact(text); }; /** diff --git a/client/src/components/Chat/Messages/Content/live.ts b/client/src/components/Chat/Messages/Content/live.ts index c28cb33cb75..df6e7bf8ebd 100644 --- a/client/src/components/Chat/Messages/Content/live.ts +++ b/client/src/components/Chat/Messages/Content/live.ts @@ -2,7 +2,7 @@ import { Constants, ContentTypes, isOpaqueReasoning, - stripOpaqueReasoning, + readableReasoning, stripToolCallErrorPrefix, } from 'librechat-data-provider'; import type { @@ -318,7 +318,8 @@ function newestLine( /** Encrypted reasoning has no sentence to preview, and a label generated * from it before labels skipped encoded payloads describes the payload. */ const opaque = isOpaqueReasoning(think); - const reasoning = stripOpaqueReasoning(think); + /** The live header only exists while the run streams. */ + const reasoning = readableReasoning(think, true); const label = opaque ? undefined : part.reasoning_label?.trim(); if (preferLabels) { /** Only a generated label will do: the generic thinking line is what diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index 5e8b793e606..ca1c4e34ecc 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -500,13 +500,14 @@ describe('createActivityPhaseWiring', () => { { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }, { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-2' } }, { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-3' } }, + { type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-4' } }, ]; const payloads: GenerateActivityPhasePayload[] = []; const wiring = createActivityPhaseWiring({ initialSnapshot: { version: 3, generated: 0, - activityCount: 3, + activityCount: 4, failedActivityCount: 0, partialActivityCount: 0, agentIds: [], @@ -529,6 +530,14 @@ describe('createActivityPhaseWiring', () => { toolCallIds: ['tool-3'], thinkingExcerpts: [`Rolling back.\n${encoded}`], }, + { + startIndex: 3, + status: 'success', + toolCallIds: ['tool-4'], + /** A window cut into a payload: only the streamed classification is evidence. */ + thinkingExcerpts: [`${encoded.slice(-9)} Verified the rollback.`], + readableExcerpts: ['Verified the rollback.'], + }, ], assistantContext: [], pendingReasoning: [], @@ -551,8 +560,10 @@ describe('createActivityPhaseWiring', () => { ['Checking the deploy manifest.'], undefined, ['Rolling back.'], + ['Verified the rollback.'], ]); expect(payloads[0].activities[1]).not.toHaveProperty('thinkingExcerpts'); + expect(payloads[0].activities[3]).not.toHaveProperty('readableExcerpts'); }); it('keeps a substantial boundary hard when too little preceding work earns a phase', async () => { @@ -901,15 +912,16 @@ describe('createActivityPhaseWiring', () => { expect(parts).toHaveLength(1); }); - it('never starts the rolling reasoning excerpt inside an encrypted payload', () => { + it('classifies streamed reasoning before bounding its prompt evidence', async () => { const encoded = Buffer.from( Array.from({ length: 1_050 }, (_, i) => (i * 37 + 3) % 256), ).toString('base64'); const readable = 'Checking the manifest before rolling back the deploy. ' .repeat(11) .slice(0, 590); + const parts: LooseContentPart[] = []; const wiring = createActivityPhaseWiring({ - getContentParts: () => [], + getContentParts: () => parts, bumpIndexOffset: jest.fn(), emitLabelEvent: jest.fn(async () => undefined), trackPendingFill: jest.fn(), @@ -931,7 +943,13 @@ describe('createActivityPhaseWiring', () => { undefined, undefined, ); - for (const think of [encoded, `\n${readable}`]) { + /** A payload-only stream longer than the 600-char window, split so its + * final 50-char fragment shares a delta with the readable reasoning. */ + const deltas = [ + ...Array.from({ length: 13 }, (_, i) => encoded.slice(i * 100, (i + 1) * 100)), + `${encoded.slice(1_300)}\n${readable}`, + ]; + for (const think of deltas) { handlers?.[GraphEvents.ON_REASONING_DELTA]?.handle( GraphEvents.ON_REASONING_DELTA, { id: 'reasoning-step', delta: { content: { type: ContentTypes.THINK, think } } } as never, @@ -939,10 +957,15 @@ describe('createActivityPhaseWiring', () => { undefined, ); } + parts.push({ type: ContentTypes.THINK, think: deltas.join('') }); + parts.push({ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }); + await wiring.hook(batch('tool-1'), new AbortController().signal); - expect(wiring.snapshot().pendingReasoning).toEqual([ - expect.objectContaining({ key: 'root', text: `\n${readable}` }), - ]); + const [activity] = wiring.snapshot().activities; + expect(activity.readableExcerpts).toEqual([readable.trim()]); + /** The raw anchor window still starts inside the payload: exactly the + * fragment that must never be prompt evidence. */ + expect(activity.thinkingExcerpts?.[0]?.startsWith(`${encoded.slice(-9)}\n`)).toBe(true); }); it('keeps reasoning attached to a tool batch across commentary', async () => { diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index f0b7d67463d..3583c1cf59b 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -2,10 +2,13 @@ import { GraphEvents } from '@librechat/agents'; import { StepTypes, ContentTypes, - sliceReasoningTail, stripOpaqueReasoning, + flushOpaqueReasoning, + advanceOpaqueReasoning, + OPAQUE_REASONING_START, } from 'librechat-data-provider'; import type { EventHandler, HookCallback, HookInputByEvent } from '@librechat/agents'; +import type { OpaqueReasoningCursor } from 'librechat-data-provider'; import type { LooseContentPart } from '~/agents/activityLabels/wiring'; import { stringifyActivityEvidence } from '~/agents/activityLabels/runtime'; @@ -31,6 +34,11 @@ export interface ActivityPhaseEntry { export type TrackedActivity = ActivityPhaseEntry & { startIndex: number; + /** Prompt evidence: the reasoning behind `thinkingExcerpts`, classified while + * it streamed so encoded payloads never reach it. `thinkingExcerpts` keep the + * raw text because anchoring matches it against THINK parts. Absent on + * activities from older snapshots, whose excerpts are stripped instead. */ + readableExcerpts?: string[]; /** A prior boundary can retain only the materialized tail of a straddling batch. */ partitionStartIndex?: number; /** Anchor-only activities keep position and status but carry no prompt evidence. */ @@ -79,6 +87,8 @@ export interface ActivityPhaseSnapshot { pendingReasoning: Array<{ key: string; text: string; + readable?: string; + cursor?: OpaqueReasoningCursor; agentId?: string; startIndex?: number; }>; @@ -202,13 +212,17 @@ function deltaText(data: unknown, key: 'text' | 'think'): string { } /** Prompt evidence only: anchors keep the raw excerpt so positions still resolve. */ -function withReadableReasoning(activity: ActivityPhaseEntry): ActivityPhaseEntry { - if (activity.thinkingExcerpts == null) { - return activity; +function withReadableReasoning( + activity: ActivityPhaseEntry & Pick, +): ActivityPhaseEntry { + const { thinkingExcerpts, readableExcerpts, ...rest } = activity; + if (thinkingExcerpts == null && readableExcerpts == null) { + return rest; } - const { thinkingExcerpts, ...rest } = activity; - const readable = thinkingExcerpts - .map((excerpt) => stripOpaqueReasoning(excerpt).trim()) + const readable = ( + readableExcerpts ?? (thinkingExcerpts ?? []).map((excerpt) => stripOpaqueReasoning(excerpt)) + ) + .map((excerpt) => excerpt.trim()) .filter((excerpt) => excerpt.length > 0); return readable.length > 0 ? { ...rest, thinkingExcerpts: readable } : rest; } @@ -445,7 +459,22 @@ export function createAssistantPhaseStampingHandlers( return wrapped; } -type PendingReasoning = { text: string; agentId?: string; startIndex?: number }; +type PendingReasoning = { + /** Raw rolling window, matched against THINK parts to anchor the activity. */ + text: string; + /** Rolling window of the same reasoning with encoded payloads removed. */ + readable: string; + cursor: OpaqueReasoningCursor; + agentId?: string; + startIndex?: number; +}; + +/** Readable evidence for a finished reasoning lane; its undecided tail settles here. */ +function readableExcerpt(reasoning: PendingReasoning): string { + return `${reasoning.readable}${flushOpaqueReasoning(reasoning.cursor)}` + .trim() + .slice(0, MAX_EXCERPT_CHARS); +} /** One side of a boundary split. Every piece of run state that a phase can * own appears here, so adding tracked state without partitioning it is a @@ -582,6 +611,7 @@ function boundedAnchor(activity: TrackedActivity): TrackedActivity { * child-label slot are deliberately not carried. */ label: undefined, entries: undefined, + readableExcerpts: undefined, childLabelIndex: undefined, }; return fields; @@ -622,6 +652,7 @@ function mergeAnchors(earlier: TrackedActivity, later: TrackedActivity): Tracked mergedAgentIds: foldedAgentIds(earlier, later), label: undefined, entries: undefined, + readableExcerpts: undefined, childLabelIndex: undefined, }; return fields; @@ -861,35 +892,43 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity let assistantContext: AssistantContextEntry[] = (initialSnapshot?.assistantContext ?? []) .slice(-MAX_CONTEXT_ITEMS) .map((entry) => (typeof entry === 'string' ? { text: entry } : { ...entry })); - const pendingReasoning = new Map( - (initialSnapshot?.pendingReasoning ?? []).map(({ key, text, agentId, startIndex }) => { - const boundedText = sliceReasoningTail(text, MAX_EXCERPT_CHARS); - const needle = boundedText.trim().slice(0, REASONING_ANCHOR_CHARS); - const rebasedStartIndex = needle ? findReasoningStart(content, boundedText, startIndex) : -1; - /** The anchor is a prefix, so two lanes can share it. Binding to the - * first match would replay a still-pending lane on the earlier side of - * a boundary and delete it; an ambiguous anchor is no anchor. */ - const matchingReasoningParts = - needle.length > 0 - ? definedPartIndices(content).filter( - (index) => - content[index]?.type === ContentTypes.THINK && - textValue(content[index]?.think).includes(needle), - ).length - : 0; - const hasMaterializedReasoning = - matchingReasoningParts === 1 && - content[rebasedStartIndex]?.type === ContentTypes.THINK && - textValue(content[rebasedStartIndex]?.think).includes(needle); - return [ - key, - { - text: boundedText, - ...(agentId != null && { agentId }), - ...(hasMaterializedReasoning && { startIndex: rebasedStartIndex }), - }, - ] as const; - }), + const pendingReasoning = new Map( + (initialSnapshot?.pendingReasoning ?? []).map( + ({ key, text, readable, cursor, agentId, startIndex }) => { + const boundedText = text.slice(-MAX_EXCERPT_CHARS); + const needle = boundedText.trim().slice(0, REASONING_ANCHOR_CHARS); + const rebasedStartIndex = needle + ? findReasoningStart(content, boundedText, startIndex) + : -1; + /** The anchor is a prefix, so two lanes can share it. Binding to the + * first match would replay a still-pending lane on the earlier side of + * a boundary and delete it; an ambiguous anchor is no anchor. */ + const matchingReasoningParts = + needle.length > 0 + ? definedPartIndices(content).filter( + (index) => + content[index]?.type === ContentTypes.THINK && + textValue(content[index]?.think).includes(needle), + ).length + : 0; + const hasMaterializedReasoning = + matchingReasoningParts === 1 && + content[rebasedStartIndex]?.type === ContentTypes.THINK && + textValue(content[rebasedStartIndex]?.think).includes(needle); + return [ + key, + { + text: boundedText, + /** Older snapshots kept only the raw window; strip it as the best + * available classification. */ + readable: readable ?? stripOpaqueReasoning(boundedText), + cursor: cursor ?? OPAQUE_REASONING_START, + ...(agentId != null && { agentId }), + ...(hasMaterializedReasoning && { startIndex: rebasedStartIndex }), + }, + ] as const; + }, + ), ); const reasoningStepKeys = new Map(); const stepKinds = new Map< @@ -1018,9 +1057,7 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity })), }), ...(activity.thinkingExcerpts != null && { - thinkingExcerpts: activity.thinkingExcerpts.map((text) => - sliceReasoningTail(text, MAX_EXCERPT_CHARS), - ), + thinkingExcerpts: activity.thinkingExcerpts.map((text) => text.slice(-MAX_EXCERPT_CHARS)), }), })), assistantContext: assistantContext @@ -1030,16 +1067,16 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity ), pendingReasoning: [...pendingReasoning].map(([key, reasoning]) => ({ key, - text: sliceReasoningTail(reasoning.text, MAX_EXCERPT_CHARS), + text: reasoning.text.slice(-MAX_EXCERPT_CHARS), + readable: reasoning.readable, + cursor: reasoning.cursor, ...(reasoning.agentId != null && { agentId: reasoning.agentId }), ...(reasoning.startIndex != null && { startIndex: reasoning.startIndex }), })), }); const addPendingReasoning = (onlyKey?: string) => { - let selected = [...pendingReasoning.entries()] as Array< - readonly [string, { text: string; agentId?: string; startIndex?: number }] - >; + let selected = [...pendingReasoning.entries()] as Array; if (onlyKey != null) { const reasoning = pendingReasoning.get(onlyKey); selected = reasoning == null ? [] : [[onlyKey, reasoning] as const]; @@ -1048,8 +1085,10 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity const text = reasoning.text.trim(); if (text) { const parts = deps.getContentParts(); + const readable = readableExcerpt(reasoning); trackActivity({ thinkingExcerpts: [text.slice(0, MAX_EXCERPT_CHARS)], + readableExcerpts: readable ? [readable] : [], ...(reasoning.agentId != null && { agentId: reasoning.agentId }), status: 'success', startIndex: findReasoningStart(parts, text, reasoning.startIndex), @@ -1379,7 +1418,9 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity * material agent transition. `input.agentId` above still excludes nested * subagent internals. */ const reasoningKey = input.executingAgentId ?? 'root'; - const reasoning = pendingReasoning.get(reasoningKey)?.text.trim(); + const pendingLane = pendingReasoning.get(reasoningKey); + const reasoning = pendingLane?.text.trim(); + const readableReasoning = pendingLane != null ? readableExcerpt(pendingLane) : ''; let trackedEntries = input.entries; let ids = new Set(trackedEntries.map((entry) => entry.toolUseId)); const parts = deps.getContentParts(); @@ -1464,7 +1505,12 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity const awaitingMaterialization = batchStartIndex == null; trackActivity({ entries, - ...(reasoning ? { thinkingExcerpts: [reasoning.slice(0, MAX_EXCERPT_CHARS)] } : {}), + ...(reasoning + ? { + thinkingExcerpts: [reasoning.slice(0, MAX_EXCERPT_CHARS)], + readableExcerpts: readableReasoning ? [readableReasoning] : [], + } + : {}), ...(input.executingAgentId != null && { agentId: input.executingAgentId }), status: activityStatus, startIndex: trackedStartIndex, @@ -1513,6 +1559,8 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity const startIndex = deps.getStepIndex?.(step.id); pendingReasoning.set(reasoningKey, { text: '', + readable: '', + cursor: OPAQUE_REASONING_START, ...(step.agentId != null && { agentId: step.agentId }), ...(startIndex != null && { startIndex }), }); @@ -1605,12 +1653,13 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity const reasoningKey = id ? reasoningStepKeys.get(id) : undefined; const reasoning = reasoningKey ? pendingReasoning.get(reasoningKey) : undefined; if (reasoning != null) { - /** Bounded without starting mid-run, so an encrypted payload cut by - * the window cannot leave an unclassifiable fragment in the excerpt. */ - reasoning.text = sliceReasoningTail( - `${reasoning.text}${deltaText(data, 'think')}`, - MAX_EXCERPT_CHARS, - ); + const think = deltaText(data, 'think'); + reasoning.text = `${reasoning.text}${think}`.slice(-MAX_EXCERPT_CHARS); + /** Classified before bounding: a window can cut a payload into a + * fragment too short to recognize, so evidence never comes from it. */ + const next = advanceOpaqueReasoning(reasoning.cursor, think); + reasoning.cursor = next.cursor; + reasoning.readable = `${reasoning.readable}${next.readable}`.slice(-MAX_EXCERPT_CHARS); } return reasoningHandler.handle(event, data, metadata, graph); }, diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index fdcdd503caf..a356e1ca5b7 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -2,6 +2,7 @@ import { GraphEvents } from '@librechat/agents'; import { StepTypes, ContentTypes, + flushOpaqueReasoning, advanceOpaqueReasoning, OPAQUE_REASONING_START, } from 'librechat-data-provider'; @@ -113,7 +114,6 @@ const MAX_TRACKED_REASONING_CHARS = 8_000; const FINAL_UPDATE_CHAR_LIMIT = 120; const OUTPUT_CHAR_LIMIT = 120; const THINK_TAGS = /<\/?think>/g; -const MAX_PENDING_WORD_CHARS = 32; const REQUEST_TIMEOUT_MS = 12_000; function textValue(value: unknown): string { @@ -516,10 +516,10 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason clearTimer(state); /** The stream is over, so a short undecided tail was a final word; a * longer one is the unfinished start of a payload. */ - const { tail } = state.cursor; - if (tail.length > 0 && tail.length <= MAX_PENDING_WORD_CHARS) { - state.text = appendBoundedReasoning(state.text, tail); - state.totalChars += tail.length; + const word = flushOpaqueReasoning(state.cursor); + if (word) { + state.text = appendBoundedReasoning(state.text, word); + state.totalChars += word.length; } state.cursor = OPAQUE_REASONING_START; schedule(state, true); diff --git a/packages/data-provider/src/reasoning.spec.ts b/packages/data-provider/src/reasoning.spec.ts index a544ce6a255..00e37dced5a 100644 --- a/packages/data-provider/src/reasoning.spec.ts +++ b/packages/data-provider/src/reasoning.spec.ts @@ -2,7 +2,8 @@ import type { OpaqueReasoningCursor } from './reasoning'; import { isOpaqueReasoning, stripOpaqueReasoning, - sliceReasoningTail, + readableReasoning, + flushOpaqueReasoning, advanceOpaqueReasoning, OPAQUE_REASONING_START, } from './reasoning'; @@ -134,19 +135,23 @@ describe('advanceOpaqueReasoning', () => { }); }); -describe('sliceReasoningTail', () => { - it('keeps short reasoning whole', () => { - expect(sliceReasoningTail('Checking the manifest.', 600)).toBe('Checking the manifest.'); +describe('readableReasoning', () => { + it('strips payloads from completed reasoning and keeps a long final token', () => { + const token = 'a'.repeat(60); + expect(readableReasoning(`Done.\n${bedrockRedacted}`)).toBe('Done.\n'); + expect(readableReasoning(`Hash ${token}`)).toBe(`Hash ${token}`); }); - it('starts after a payload the window would cut', () => { - const text = `${bedrockRedacted}\nRolling back the deploy.`; - const tail = sliceReasoningTail(text, 100); - expect(tail).toBe('\nRolling back the deploy.'); - expect(isOpaqueReasoning(tail)).toBe(false); + it('withholds a trailing run that could still become a payload while streaming', () => { + const prefix = bedrockRedacted.slice(0, 100); + expect(readableReasoning(`Checking. ${prefix}`, true)).toBe('Checking. '); + expect(readableReasoning(prefix, true)).toBe(''); + expect(readableReasoning('Checking the manif', true)).toBe('Checking the manif'); }); - it('cuts at the window boundary when it falls between words', () => { - expect(sliceReasoningTail('alpha beta gamma', 6)).toBe(' gamma'); + it('flushes only a word-sized tail', () => { + expect(flushOpaqueReasoning({ tail: 'manifest', opaque: false })).toBe('manifest'); + expect(flushOpaqueReasoning({ tail: 'a'.repeat(40), opaque: false })).toBe(''); + expect(flushOpaqueReasoning({ tail: '', opaque: true })).toBe(''); }); }); diff --git a/packages/data-provider/src/reasoning.ts b/packages/data-provider/src/reasoning.ts index 6d64678821b..2552048d6ff 100644 --- a/packages/data-provider/src/reasoning.ts +++ b/packages/data-provider/src/reasoning.ts @@ -6,6 +6,8 @@ * `encrypted_content`, signatures) is one long run of these characters. */ const OPAQUE_REASONING_MIN_RUN = 128; +/** Longest undecided tail released as a final word once its stream has ended. */ +const OPAQUE_REASONING_MAX_WORD = 32; const OPAQUE_REASONING_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${OPAQUE_REASONING_MIN_RUN},}`, 'g'); /** Persisted reasoning is stored inside `` tags; they are not readable content. */ const THINK_TAGS = /<\/?think>/g; @@ -91,17 +93,23 @@ export function advanceOpaqueReasoning( } /** - * The last `maxChars` of reasoning for a rolling excerpt. A cut inside an - * unbroken run would leave a fragment too short to classify, so a window that - * starts mid-run skips to the end of that run. + * The undecided tail of a stream that has ended (or of text still streaming, + * for display): a word-sized tail is a final word, while a longer one is more + * likely the unclassified start of a payload and is withheld. */ -export function sliceReasoningTail(text: string, maxChars: number): string { - if (text.length <= maxChars) { - return text; - } - const start = text.length - maxChars; - if (!isPayloadChar(text.charCodeAt(start - 1))) { - return text.slice(start); +export function flushOpaqueReasoning(cursor: OpaqueReasoningCursor): string { + return !cursor.opaque && cursor.tail.length <= OPAQUE_REASONING_MAX_WORD ? cursor.tail : ''; +} + +/** + * Reasoning text safe to show: encoded payloads removed and, while the text is + * still streaming, a trailing run that could still grow into a payload held + * back until it is classified. + */ +export function readableReasoning(text: string, streaming = false): string { + if (!streaming) { + return stripOpaqueReasoning(text); } - return text.slice(payloadRunEnd(text, start)); + const { readable, cursor } = advanceOpaqueReasoning(OPAQUE_REASONING_START, text); + return `${readable}${flushOpaqueReasoning(cursor)}`; } From b2d7c79b026ef8d1c21229c16caeaaf6ce8d9787 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 14:08:33 -0400 Subject: [PATCH 6/9] fix: Replay legacy phase snapshots through the reasoning stream filter A pending-reasoning lane restored from a snapshot without a readable window or cursor is replayed through advanceOpaqueReasoning instead of stripped, so a payload prefix that was still undecided at the snapshot stays in the cursor and is dropped once the continuation classifies it. --- .../src/agents/activityPhases/runtime.spec.ts | 56 +++++++++++++++++++ .../api/src/agents/activityPhases/runtime.ts | 13 +++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index ca1c4e34ecc..932513fcbec 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -912,6 +912,62 @@ describe('createActivityPhaseWiring', () => { expect(parts).toHaveLength(1); }); + it('replays a legacy pending-reasoning snapshot through the stream filter', async () => { + const encoded = Buffer.from( + Array.from({ length: 1_050 }, (_, i) => (i * 37 + 3) % 256), + ).toString('base64'); + const parts: LooseContentPart[] = []; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 3, + generated: 0, + activityCount: 0, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [], + assistantContext: [], + /** Written before snapshots carried a readable window or cursor. */ + pendingReasoning: [{ key: 'root', text: `Checking. ${encoded.slice(0, 100)}` }], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'unused' })), + }); + const handlers = wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: jest.fn() }, + [GraphEvents.ON_REASONING_DELTA]: { handle: jest.fn() }, + }); + handlers?.[GraphEvents.ON_RUN_STEP]?.handle( + GraphEvents.ON_RUN_STEP, + { + id: 'reasoning-step', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { message_id: 'm', content_type: 'think' }, + }, + } as never, + undefined, + undefined, + ); + for (const think of [encoded.slice(100), ' Rolling back.']) { + handlers?.[GraphEvents.ON_REASONING_DELTA]?.handle( + GraphEvents.ON_REASONING_DELTA, + { id: 'reasoning-step', delta: { content: { type: ContentTypes.THINK, think } } } as never, + undefined, + undefined, + ); + } + parts.push({ type: ContentTypes.THINK, think: `Checking. ${encoded} Rolling back.` }); + parts.push({ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + + const [activity] = wiring.snapshot().activities; + expect(activity.readableExcerpts).toEqual(['Checking. Rolling back.']); + }); + it('classifies streamed reasoning before bounding its prompt evidence', async () => { const encoded = Buffer.from( Array.from({ length: 1_050 }, (_, i) => (i * 37 + 3) % 256), diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index 3583c1cf59b..fd6974464b1 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -915,14 +915,19 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity matchingReasoningParts === 1 && content[rebasedStartIndex]?.type === ContentTypes.THINK && textValue(content[rebasedStartIndex]?.think).includes(needle); + /** Older snapshots kept only the raw window. Replaying it through the + * stream filter keeps a payload prefix that was still undecided at the + * snapshot in the cursor, where the continuation can classify it. */ + const classified = + readable != null && cursor != null + ? { readable, cursor } + : advanceOpaqueReasoning(OPAQUE_REASONING_START, boundedText); return [ key, { text: boundedText, - /** Older snapshots kept only the raw window; strip it as the best - * available classification. */ - readable: readable ?? stripOpaqueReasoning(boundedText), - cursor: cursor ?? OPAQUE_REASONING_START, + readable: classified.readable, + cursor: classified.cursor, ...(agentId != null && { agentId }), ...(hasMaterializedReasoning && { startIndex: rebasedStartIndex }), }, From e74afd66f7bb1cd9ce11a392eb7588bc5ca369af Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 14:16:00 -0400 Subject: [PATCH 7/9] fix: Drop the cut leading run of a full legacy phase window A legacy pending-reasoning window that filled the 600-char bound may have been cut inside a payload, so its leading payload-alphabet run is dropped before the replay; a shorter window is the whole lane and is replayed as is. --- .../src/agents/activityPhases/runtime.spec.ts | 36 +++++++++++++++++++ .../api/src/agents/activityPhases/runtime.ts | 12 +++++-- packages/data-provider/src/reasoning.spec.ts | 10 ++++++ packages/data-provider/src/reasoning.ts | 8 +++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/api/src/agents/activityPhases/runtime.spec.ts b/packages/api/src/agents/activityPhases/runtime.spec.ts index 932513fcbec..1eb4b1735f6 100644 --- a/packages/api/src/agents/activityPhases/runtime.spec.ts +++ b/packages/api/src/agents/activityPhases/runtime.spec.ts @@ -968,6 +968,42 @@ describe('createActivityPhaseWiring', () => { expect(activity.readableExcerpts).toEqual(['Checking. Rolling back.']); }); + it('drops the cut leading run of a full legacy pending-reasoning window', async () => { + const encoded = Buffer.from( + Array.from({ length: 1_050 }, (_, i) => (i * 37 + 3) % 256), + ).toString('base64'); + const readable = 'Checking the manifest before rolling back the deploy. ' + .repeat(11) + .slice(0, 549); + const legacyWindow = `${encoded.slice(-50)} ${readable}`; + expect(legacyWindow).toHaveLength(600); + const parts: LooseContentPart[] = []; + const wiring = createActivityPhaseWiring({ + initialSnapshot: { + version: 3, + generated: 0, + activityCount: 0, + failedActivityCount: 0, + partialActivityCount: 0, + agentIds: [], + activities: [], + assistantContext: [], + pendingReasoning: [{ key: 'root', text: legacyWindow }], + }, + getContentParts: () => parts, + bumpIndexOffset: jest.fn(), + emitLabelEvent: jest.fn(async () => undefined), + trackPendingFill: jest.fn(), + generatePhase: jest.fn(async () => ({ label: 'unused' })), + }); + parts.push({ type: ContentTypes.THINK, think: `${encoded} ${readable}` }); + parts.push({ type: ContentTypes.TOOL_CALL, tool_call: { id: 'tool-1' } }); + await wiring.hook(batch('tool-1'), new AbortController().signal); + + const [activity] = wiring.snapshot().activities; + expect(activity.readableExcerpts).toEqual([readable.trim()]); + }); + it('classifies streamed reasoning before bounding its prompt evidence', async () => { const encoded = Buffer.from( Array.from({ length: 1_050 }, (_, i) => (i * 37 + 3) % 256), diff --git a/packages/api/src/agents/activityPhases/runtime.ts b/packages/api/src/agents/activityPhases/runtime.ts index fd6974464b1..bbe02322764 100644 --- a/packages/api/src/agents/activityPhases/runtime.ts +++ b/packages/api/src/agents/activityPhases/runtime.ts @@ -5,6 +5,7 @@ import { stripOpaqueReasoning, flushOpaqueReasoning, advanceOpaqueReasoning, + trimTruncatedReasoning, OPAQUE_REASONING_START, } from 'librechat-data-provider'; import type { EventHandler, HookCallback, HookInputByEvent } from '@librechat/agents'; @@ -917,11 +918,18 @@ export function createActivityPhaseWiring(deps: ActivityPhaseHostDeps): Activity textValue(content[rebasedStartIndex]?.think).includes(needle); /** Older snapshots kept only the raw window. Replaying it through the * stream filter keeps a payload prefix that was still undecided at the - * snapshot in the cursor, where the continuation can classify it. */ + * snapshot in the cursor, where the continuation can classify it; a + * full window may have been cut inside a payload, so its leading run + * is dropped. */ const classified = readable != null && cursor != null ? { readable, cursor } - : advanceOpaqueReasoning(OPAQUE_REASONING_START, boundedText); + : advanceOpaqueReasoning( + OPAQUE_REASONING_START, + text.length > MAX_EXCERPT_CHARS || boundedText.length === MAX_EXCERPT_CHARS + ? trimTruncatedReasoning(boundedText) + : boundedText, + ); return [ key, { diff --git a/packages/data-provider/src/reasoning.spec.ts b/packages/data-provider/src/reasoning.spec.ts index 00e37dced5a..1c28407be3d 100644 --- a/packages/data-provider/src/reasoning.spec.ts +++ b/packages/data-provider/src/reasoning.spec.ts @@ -4,6 +4,7 @@ import { stripOpaqueReasoning, readableReasoning, flushOpaqueReasoning, + trimTruncatedReasoning, advanceOpaqueReasoning, OPAQUE_REASONING_START, } from './reasoning'; @@ -155,3 +156,12 @@ describe('readableReasoning', () => { expect(flushOpaqueReasoning({ tail: '', opaque: true })).toBe(''); }); }); + +describe('trimTruncatedReasoning', () => { + it('drops a leading fragment and keeps the rest', () => { + expect(trimTruncatedReasoning(`${bedrockRedacted.slice(-40)} Rolling back.`)).toBe( + ' Rolling back.', + ); + expect(trimTruncatedReasoning(' Rolling back.')).toBe(' Rolling back.'); + }); +}); diff --git a/packages/data-provider/src/reasoning.ts b/packages/data-provider/src/reasoning.ts index 2552048d6ff..c06d1495a5c 100644 --- a/packages/data-provider/src/reasoning.ts +++ b/packages/data-provider/src/reasoning.ts @@ -113,3 +113,11 @@ export function readableReasoning(text: string, streaming = false): string { const { readable, cursor } = advanceOpaqueReasoning(OPAQUE_REASONING_START, text); return `${readable}${flushOpaqueReasoning(cursor)}`; } + +/** + * A bounded window that may have been cut mid-run starts with a fragment too + * short to classify, whether of a word or of a payload; drop it. + */ +export function trimTruncatedReasoning(text: string): string { + return text.slice(payloadRunEnd(text, 0)); +} From a334a99c7dd9c391c655371660804ab40b483a63 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 14:26:06 -0400 Subject: [PATCH 8/9] fix: Keep payload-bearing reasoning read-only and drop payload-derived titles on resume - reasoning that carries an encoded payload is not offered in the message editor: the edit endpoint replaces the whole value and the payload cannot be positioned in an edited draft, so editing would silently delete it - a resumed reasoning step whose persisted text has no readable reasoning drops its existing title (which could only describe the payload) instead of carrying it forward where a short readable continuation would revive it --- .../Messages/Content/EditContentParts.tsx | 4 +-- .../__tests__/EditContentParts.spec.tsx | 13 +++++----- .../Content/__tests__/editableParts.spec.ts | 23 +++++++++++----- .../Chat/Messages/Content/editableParts.ts | 17 ++++-------- .../agents/reasoningLabels/runtime.spec.ts | 26 +++++++++++++++++++ .../api/src/agents/reasoningLabels/runtime.ts | 15 ++++++++--- 6 files changed, 67 insertions(+), 31 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/EditContentParts.tsx b/client/src/components/Chat/Messages/Content/EditContentParts.tsx index 79769efcaeb..0f25001a0ee 100644 --- a/client/src/components/Chat/Messages/Content/EditContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/EditContentParts.tsx @@ -9,8 +9,8 @@ import { } from 'librechat-data-provider'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { ReactNode } from 'react'; -import { getEditableText, isEditablePart, withPartText } from './editableParts'; import { useMessagesConversation, useMessagesOperations } from '~/Providers'; +import { getPartText, isEditablePart, withPartText } from './editableParts'; import { findRerunParent } from './rerunParent'; import { useGetAddedConvo } from '~/hooks/Chat'; import { useLocalize } from '~/hooks'; @@ -65,7 +65,7 @@ export default function EditContentParts({ if (!part || !isEditablePart(part)) { return; } - const original = getEditableText(part); + const original = getPartText(part); if (original == null) { return; } diff --git a/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx b/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx index eeafd20f618..46f4e215c09 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx +++ b/client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx @@ -239,14 +239,16 @@ describe('EditContentParts', () => { ]); }); - it('edits mixed reasoning without showing its encrypted payload', () => { + it('keeps reasoning with an encrypted payload out of the editor', () => { const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( 'base64', ); const mixedContent = [ { type: ContentTypes.THINK, think: `Checking the manifest.\n${encoded}` }, + { type: ContentTypes.TEXT, text: 'Original response' }, ] as TMessageContentParts[]; message.content = mixedContent; + const renderReadOnlyPart = jest.fn(() => null); render( { enterEdit={jest.fn()} siblingIdx={0} setSiblingIdx={jest.fn()} - renderReadOnlyPart={() => null} + renderReadOnlyPart={renderReadOnlyPart} />, ); - const editor = screen.getByRole('textbox') as HTMLTextAreaElement; - expect(editor.value).toBe('Checking the manifest.\n'); - /** Unchanged, so nothing is written and the stored payload stays intact. */ - expect(screen.getByRole('button', { name: 'com_ui_save' })).toBeDisabled(); + const editors = screen.getAllByRole('textbox') as HTMLTextAreaElement[]; + expect(editors.map((editor) => editor.value)).toEqual(['Original response']); + expect(renderReadOnlyPart).toHaveBeenCalledWith(mixedContent[0], 0, false); }); it('reruns an assistant response with the edited content value', () => { diff --git a/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts b/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts index 93d1e636c4e..72c522e64dd 100644 --- a/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts +++ b/client/src/components/Chat/Messages/Content/__tests__/editableParts.spec.ts @@ -1,6 +1,6 @@ import { ContentTypes } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; -import { getEditableText, hasEditablePart, isEditablePart } from '../editableParts'; +import { hasEditablePart, isEditablePart } from '../editableParts'; const artifactText = ':::artifact{identifier="demo" type="text/html" title="Demo"}\n
\n:::'; @@ -70,17 +70,26 @@ describe('encrypted reasoning in the editor', () => { ).toBe(false); }); - it('offers only the readable text of mixed reasoning', () => { + it('keeps mixed reasoning read-only so an edit cannot drop its payload', () => { const part = { type: ContentTypes.THINK, think: `Checking the manifest. ${encoded}`, } as TMessageContentParts; - expect(isEditablePart(part)).toBe(true); - expect(getEditableText(part)).toBe('Checking the manifest. '); + expect(isEditablePart(part)).toBe(false); }); - it('leaves text parts untouched', () => { - const part = { type: ContentTypes.TEXT, text: `Token: ${encoded}` } as TMessageContentParts; - expect(getEditableText(part)).toBe(`Token: ${encoded}`); + it('still edits readable reasoning and any text part', () => { + expect( + isEditablePart({ + type: ContentTypes.THINK, + think: 'Checking the manifest.', + } as TMessageContentParts), + ).toBe(true); + expect( + isEditablePart({ + type: ContentTypes.TEXT, + text: `Token: ${encoded}`, + } as TMessageContentParts), + ).toBe(true); }); }); diff --git a/client/src/components/Chat/Messages/Content/editableParts.ts b/client/src/components/Chat/Messages/Content/editableParts.ts index de470ec3119..7a1e126215f 100644 --- a/client/src/components/Chat/Messages/Content/editableParts.ts +++ b/client/src/components/Chat/Messages/Content/editableParts.ts @@ -1,4 +1,4 @@ -import { ContentTypes, isOpaqueReasoning, stripOpaqueReasoning } from 'librechat-data-provider'; +import { ContentTypes, stripOpaqueReasoning } from 'librechat-data-provider'; import type { TMessage, TMessageContentParts, TextData } from 'librechat-data-provider'; import { splitMarkdownIntoBlocks } from './splitMarkdown'; @@ -21,16 +21,6 @@ export const getPartText = (part: TMessageContentParts): string | undefined => { return typeof value === 'string' ? value : value?.value; }; -/** - * The text the editor offers for a part. Encoded provider reasoning is not - * prose anyone can edit, so a THINK part offers only its readable text; a part - * left unchanged is never written back, which keeps the stored payload intact. - */ -export const getEditableText = (part: TMessageContentParts): string | undefined => { - const text = getPartText(part); - return part.type === ContentTypes.THINK && text != null ? stripOpaqueReasoning(text) : text; -}; - export const withPartText = (part: TMessageContentParts, text: string): string | TextData => { const value = getPartValue(part); return value != null && typeof value === 'object' ? { ...value, value: text } : text; @@ -63,7 +53,10 @@ export const isEditablePart = (part?: TMessageContentParts | null): boolean => { if (text == null) { return false; } - if (part.type === ContentTypes.THINK && isOpaqueReasoning(text)) { + /** The edit endpoint replaces the whole value, and an encoded provider + * payload is neither readable nor positionable in an edited draft, so + * reasoning that carries one stays read-only rather than losing it. */ + if (part.type === ContentTypes.THINK && stripOpaqueReasoning(text) !== text) { return false; } return !containsArtifact(text); diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index fa4ea1fe726..88b223b5481 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -1074,4 +1074,30 @@ describe('reasoning labels for encoded reasoning', () => { expect(payloadArg.visibleReasoning).toContain('Rolling back the deploy now.'); } }); + it('does not revive a payload-derived title when a resumed step continues briefly', async () => { + const generate = jest.fn(async () => ({ label: 'unused' })); + const harness = createHarness(generate, { + updateIntervalMs: 0, + preservePartOnStart: true, + initialParts: [ + { + type: ContentTypes.THINK, + think: encodedReasoning(), + reasoning_label: 'Inspecting an encoded data snapshot', + reasoning_label_step_id: 'reasoning-1', + reasoning_label_revision: 1, + reasoning_label_status: 'complete', + reasoning_label_attempts: 1, + }, + ], + }); + await harness.start(); + await harness.append(' Checking the tool result.'); + await harness.close(); + await harness.settle(); + + expect(generate).not.toHaveBeenCalled(); + expect(harness.parts[0]?.reasoning_label).toBeUndefined(); + expect(harness.events).toEqual([]); + }); }); diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index a356e1ca5b7..095d8bd873b 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -559,10 +559,6 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason const part = index != null ? deps.getContentParts()[index] : undefined; const ownsExistingStep = part?.type === ContentTypes.THINK && part.reasoning_label_step_id === step.id; - const existingLabel = - ownsExistingStep && typeof part.reasoning_label === 'string' - ? part.reasoning_label - : undefined; /** Replayed through the stream filter so a payload that was still * undecided at the snapshot keeps its cursor and is dropped once the * continuation classifies it, instead of settling as readable text. */ @@ -573,6 +569,14 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason ) : { readable: '', cursor: OPAQUE_REASONING_START }; const existingText = resumed.readable; + /** A title on a step with no readable reasoning can only describe an + * encoded payload (labeled before payloads were skipped), so it is not + * carried forward where a short readable continuation would revive it. */ + const staleLabel = ownsExistingStep && existingText.trim().length === 0; + const existingLabel = + ownsExistingStep && !staleLabel && typeof part.reasoning_label === 'string' + ? part.reasoning_label + : undefined; const persistedSubmittedChars = ownsExistingStep && typeof part.reasoning_label_submitted_chars === 'number' ? Math.min(part.reasoning_label_submitted_chars, existingText.trim().length) @@ -610,6 +614,9 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason delete part.reasoning_label_revision; delete part.reasoning_label_status; delete part.reasoning_label_submitted_chars; + } else if (staleLabel) { + delete part.reasoning_label; + delete part.reasoning_label_status; } Object.assign(part, { reasoning_label_step_id: step.id, From d68368e6fd22b6557b9c71283e99c3e1d1b75dea Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 14 Oct 2026 14:35:20 -0400 Subject: [PATCH 9/9] fix: Reset a resumed payload-derived title on the client, only for payload-only steps - a resumed step drops its title only when its persisted reasoning is entirely an encoded payload, so a short readable token (still undecided for the stream cursor) keeps a valid title - dropping it emits a same-step on_reasoning_label reset, which the client already applies, so a resumed client clears the snapshot title too --- .../src/agents/reasoningLabels/host.spec.ts | 47 ++++++++++++++++++- .../api/src/agents/reasoningLabels/host.ts | 8 +++- .../api/src/agents/reasoningLabels/index.ts | 1 + .../agents/reasoningLabels/runtime.spec.ts | 40 ++++++++++++++++ .../api/src/agents/reasoningLabels/runtime.ts | 37 +++++++++++++-- 5 files changed, 127 insertions(+), 6 deletions(-) diff --git a/packages/api/src/agents/reasoningLabels/host.spec.ts b/packages/api/src/agents/reasoningLabels/host.spec.ts index 7ca2808e645..cc06643f78a 100644 --- a/packages/api/src/agents/reasoningLabels/host.spec.ts +++ b/packages/api/src/agents/reasoningLabels/host.spec.ts @@ -1,4 +1,5 @@ -import { ContentTypes } from 'librechat-data-provider'; +import { GraphEvents } from '@librechat/agents'; +import { ContentTypes, StepTypes, ReasoningLabelEvents } from 'librechat-data-provider'; import { createReasoningLabelHostWiring, getLabelUsageSequenceSeed, @@ -66,4 +67,48 @@ describe('reasoning label host', () => { expect(markResumable).toHaveBeenCalledTimes(2); expect(onMarkFailure).not.toHaveBeenCalled(); }); + + it('emits a same-step reset for a resumed step titled from an encoded payload', async () => { + const encoded = Buffer.from(Array.from({ length: 900 }, (_, i) => (i * 37 + 3) % 256)).toString( + 'base64', + ); + const parts = [ + { + type: ContentTypes.THINK, + think: encoded, + reasoning_label: 'Inspecting an encoded data snapshot', + reasoning_label_step_id: 'reasoning-1', + reasoning_label_revision: 1, + reasoning_label_attempts: 1, + }, + ]; + const emitEvent = jest.fn(async () => undefined); + const pending: Promise[] = []; + const host = createHost({ + seedFromContent: true, + getContentParts: () => parts, + getStepIndex: (stepId) => (stepId === 'reasoning-1' ? 0 : undefined), + emitEvent, + trackPendingFill: (task) => pending.push(task), + }); + const handlers = host.wiring.handlers({ + [GraphEvents.ON_RUN_STEP]: { handle: () => undefined }, + }); + await handlers?.[GraphEvents.ON_RUN_STEP]?.handle(GraphEvents.ON_RUN_STEP, { + id: 'reasoning-1', + stepDetails: { + type: StepTypes.MESSAGE_CREATION, + message_creation: { content_type: ContentTypes.THINK }, + }, + } as never); + await Promise.all(pending); + + expect(emitEvent).toHaveBeenCalledWith(ReasoningLabelEvents.ON_REASONING_LABEL, { + index: 0, + stepId: 'reasoning-1', + previousStepId: 'reasoning-1', + attempts: 1, + reset: true, + }); + }); }); diff --git a/packages/api/src/agents/reasoningLabels/host.ts b/packages/api/src/agents/reasoningLabels/host.ts index f4e49875a49..cf4f8a54047 100644 --- a/packages/api/src/agents/reasoningLabels/host.ts +++ b/packages/api/src/agents/reasoningLabels/host.ts @@ -5,6 +5,7 @@ import type { GeneratedReasoningLabel, GenerateReasoningLabelPayload, ReasoningLabelAttemptEvent, + ReasoningLabelResetEvent, ReasoningLabelEvent, ReasoningLabelHostDeps, ReasoningLabelStatus, @@ -89,7 +90,10 @@ export interface ReasoningLabelHostScope { detach?: () => void; } -type ReasoningLabelDurableEvent = ReasoningLabelAttemptEvent | ReasoningLabelEvent; +type ReasoningLabelDurableEvent = + | ReasoningLabelAttemptEvent + | ReasoningLabelEvent + | (ReasoningLabelResetEvent & { reset: true }); export interface CreateReasoningLabelHostWiringOptions { config: ResolvedReasoningLabelConfig; @@ -197,6 +201,8 @@ export function createReasoningLabelHostWiring({ getStepIndex, emitAttemptEvent: (event) => emitEvent(ReasoningLabelEvents.ON_REASONING_LABEL_ATTEMPT, event), emitLabelEvent: (event) => emitEvent(ReasoningLabelEvents.ON_REASONING_LABEL, event), + emitResetEvent: (event) => + emitEvent(ReasoningLabelEvents.ON_REASONING_LABEL, { ...event, reset: true }), trackPendingFill, generateLabel, }); diff --git a/packages/api/src/agents/reasoningLabels/index.ts b/packages/api/src/agents/reasoningLabels/index.ts index 2a5d8fae4b0..9b48925d263 100644 --- a/packages/api/src/agents/reasoningLabels/index.ts +++ b/packages/api/src/agents/reasoningLabels/index.ts @@ -10,6 +10,7 @@ export type { ReasoningLabelAttemptEvent, ReasoningLabelEvent, ReasoningLabelHostDeps, + ReasoningLabelResetEvent, ReasoningLabelStatus, ReasoningLabelWiring, } from './runtime'; diff --git a/packages/api/src/agents/reasoningLabels/runtime.spec.ts b/packages/api/src/agents/reasoningLabels/runtime.spec.ts index 88b223b5481..ad667b83066 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.spec.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.spec.ts @@ -4,6 +4,7 @@ import type { EventHandler } from '@librechat/agents'; import type { GeneratedReasoningLabel, ReasoningLabelAttemptEvent, + ReasoningLabelResetEvent, ReasoningLabelEvent, } from './runtime'; import type { LooseContentPart } from '~/agents/activityLabels/wiring'; @@ -32,6 +33,7 @@ function createHarness( preservePartOnStart?: boolean; emitAttemptEvent?: (event: ReasoningLabelAttemptEvent) => Promise; emitLabelEvent?: (event: ReasoningLabelEvent) => Promise; + emitResetEvent?: (event: ReasoningLabelResetEvent) => Promise; } = {}, ) { const { initialParts, preservePartOnStart, emitAttemptEvent, emitLabelEvent, ...wiringConfig } = @@ -1076,8 +1078,10 @@ describe('reasoning labels for encoded reasoning', () => { }); it('does not revive a payload-derived title when a resumed step continues briefly', async () => { const generate = jest.fn(async () => ({ label: 'unused' })); + const emitResetEvent = jest.fn(async () => undefined); const harness = createHarness(generate, { updateIntervalMs: 0, + emitResetEvent, preservePartOnStart: true, initialParts: [ { @@ -1099,5 +1103,41 @@ describe('reasoning labels for encoded reasoning', () => { expect(generate).not.toHaveBeenCalled(); expect(harness.parts[0]?.reasoning_label).toBeUndefined(); expect(harness.events).toEqual([]); + /** The resumed client still holds the snapshot title for this same step. */ + expect(emitResetEvent).toHaveBeenCalledWith({ + index: 0, + stepId: 'reasoning-1', + previousStepId: 'reasoning-1', + attempts: 1, + }); + }); + + it('keeps the title of a resumed step whose reasoning is a short readable token', async () => { + const generate = jest.fn(async () => ({ label: 'unused' })); + const emitResetEvent = jest.fn(async () => undefined); + const harness = createHarness(generate, { + minChars: 5, + updateIntervalMs: 0, + emitResetEvent, + preservePartOnStart: true, + initialParts: [ + { + type: ContentTypes.THINK, + think: 'Investigating', + reasoning_label: 'Investigating the failure', + reasoning_label_step_id: 'reasoning-1', + reasoning_label_revision: 1, + reasoning_label_status: 'streaming', + reasoning_label_attempts: 1, + reasoning_label_submitted_chars: 13, + }, + ], + }); + await harness.start(); + await harness.close(); + await harness.settle(); + + expect(emitResetEvent).not.toHaveBeenCalled(); + expect(harness.parts[0]?.reasoning_label).toBe('Investigating the failure'); }); }); diff --git a/packages/api/src/agents/reasoningLabels/runtime.ts b/packages/api/src/agents/reasoningLabels/runtime.ts index 095d8bd873b..7c2b2535d7e 100644 --- a/packages/api/src/agents/reasoningLabels/runtime.ts +++ b/packages/api/src/agents/reasoningLabels/runtime.ts @@ -3,6 +3,7 @@ import { StepTypes, ContentTypes, flushOpaqueReasoning, + isOpaqueReasoning, advanceOpaqueReasoning, OPAQUE_REASONING_START, } from 'librechat-data-provider'; @@ -46,6 +47,14 @@ export interface ReasoningLabelAttemptEvent { submittedChars: number; } +/** Clears a title the client already holds for a step that keeps its identity. */ +export interface ReasoningLabelResetEvent { + index: number; + stepId: string; + previousStepId: string; + attempts: number; +} + export interface ReasoningLabelHostDeps { minChars?: number; updateChars?: number; @@ -60,6 +69,7 @@ export interface ReasoningLabelHostDeps { getStepIndex: (stepId: string) => number | undefined; emitAttemptEvent: (event: ReasoningLabelAttemptEvent) => Promise; emitLabelEvent: (event: ReasoningLabelEvent) => Promise; + emitResetEvent?: (event: ReasoningLabelResetEvent) => Promise; trackPendingFill: (fillDone: Promise) => void; isClosed?: () => boolean; generateLabel: (payload: GenerateReasoningLabelPayload) => Promise; @@ -569,10 +579,14 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason ) : { readable: '', cursor: OPAQUE_REASONING_START }; const existingText = resumed.readable; - /** A title on a step with no readable reasoning can only describe an - * encoded payload (labeled before payloads were skipped), so it is not - * carried forward where a short readable continuation would revive it. */ - const staleLabel = ownsExistingStep && existingText.trim().length === 0; + /** A title on a step whose reasoning is entirely an encoded payload can + * only describe that payload (labeled before payloads were skipped), so it + * is not carried forward where a short readable continuation would + * revive it. */ + const staleLabel = + ownsExistingStep && + typeof part.reasoning_label === 'string' && + isOpaqueReasoning(textValue(part.think)); const existingLabel = ownsExistingStep && !staleLabel && typeof part.reasoning_label === 'string' ? part.reasoning_label @@ -623,6 +637,21 @@ export function createReasoningLabelWiring(deps: ReasoningLabelHostDeps): Reason reasoning_label_attempts: generated, }); } + if (staleLabel && index != null && deps.emitResetEvent != null) { + /** The resumed client still renders the snapshot's title; the step keeps + * its identity, so only an explicit reset clears it. */ + const resetTask = Promise.resolve( + deps.emitResetEvent({ + index, + stepId: step.id, + previousStepId: step.id, + attempts: generated, + }), + ) + .then(() => undefined) + .catch(() => undefined); + deps.trackPendingFill(resetTask); + } schedule(state); };