Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions client/src/components/Chat/Messages/Content/Part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ContentTypes,
ToolCallTypes,
imageGenTools,
isOpaqueReasoning,
isImageVisionTool,
hasToolCallPreview,
getToolCallPreviewRevision,
Expand Down Expand Up @@ -646,6 +647,9 @@ const renderers: PartRenderers = {
if (reasoning.trim() === '' && part.reasoning_unavailable === true) {
return <ReasoningMarker label={part.reasoning_label} />;
}
if (isOpaqueReasoning(reasoning)) {
return <ReasoningMarker encrypted />;
Comment on lines +650 to +651

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide payload prefixes while classification is pending

When encrypted reasoning is streamed in small deltas, isOpaqueReasoning remains false until the accumulated unbroken run reaches 128 characters, so the first 1–127 ciphertext characters are passed to Reasoning, rendered in the live thought panel, and exposed to its copy control before being replaced by the encrypted marker. Defer the undecided payload-alphabet prefix during streaming, rather than rendering it while classification is pending.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 79a194d. A new readableReasoning(text, streaming) runs the streaming classifier over the text. While a part is streaming, it withholds a trailing payload-alphabet run longer than a word (32 chars) until the run is classified. Reasoning (when submitting and last), ReasoningCompact (isStreaming) and the live header use it, so the first characters of a streaming payload are never rendered or copyable. A pure prefix renders nothing until it becomes the encrypted marker. Covered in ReasoningCompact.test.tsx, LiveParity.test.tsx and reasoning.spec.ts.

}
Comment on lines +650 to +652

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Strip opaque spans from mixed reasoning before rendering

When a THINK part contains readable prose alongside an encrypted provider payload, isOpaqueReasoning intentionally returns false because some readable content remains, so the original unfiltered string is passed to Reasoning and the ciphertext is displayed and offered by the thought copy control; the grouped renderer has the same all-or-nothing check. Fresh evidence after cb8ec47 is that the new stripOpaqueReasoning tests explicitly support readable text surrounding an encoded payload. Render the stripped readable value, reserving the marker for parts with no readable remainder.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5da1460. Reasoning and ReasoningCompact now render and copy stripOpaqueReasoning(...), the readable text with any encoded payload removed. The live header previews from the same stripped text, and the conversation export (hooks/Conversations/format.ts) also drops payloads. The encrypted marker is still reserved for parts with no readable remainder. Covered by ReasoningCompact.test.tsx, LiveParity.test.tsx and format.test.ts.

return (
<Reasoning
partKeyIndex={getPartKeyIndex(part, partIndex ?? 0)}
Expand Down
47 changes: 34 additions & 13 deletions client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, readableReasoning } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import {
ThinkingContent,
Expand All @@ -26,6 +26,12 @@ const stripThinkTags = (reasoning: string): string =>
.replace(/\s*<\/think>$/, '')
.trim();

/** 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;

/** Tail of streaming reasoning, specifically the last few sentences, for the collapsed
Expand Down Expand Up @@ -140,13 +146,23 @@ type ReasoningProps = {
* For legacy text-based messages, see Thinking.tsx component.
*/
/** Reasoning that happened but whose text this view cannot show — detached
* subagent projections keep only a marker. Renders the shared reasoning
* header row without a disclosure. */
export const ReasoningMarker = memo(({ label }: { label?: string }) => {
const localize = useLocalize();
const display = label?.trim() || localize('com_ui_thoughts');
return <ThinkingLabel label={display} title={localize('com_ui_thoughts_unavailable')} />;
});
* 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 (
<ThinkingLabel
label={localize('com_ui_reasoning_encrypted')}
title={localize('com_ui_reasoning_encrypted_description')}
/>
);
}
const display = label?.trim() || localize('com_ui_thoughts');
return <ThinkingLabel label={display} title={localize('com_ui_thoughts_unavailable')} />;
},
);

ReasoningMarker.displayName = 'ReasoningMarker';

Expand All @@ -166,9 +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;

// Strip <think> tags from the reasoning content (modern format)
const reasoningText = useMemo(() => stripThinkTags(reasoning), [reasoning]);
const reasoningText = useMemo(
() => displayReasoning(reasoning, streaming),
[reasoning, streaming],
);

const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
Expand Down Expand Up @@ -201,8 +221,6 @@ const Reasoning = memo((props: ReasoningProps) => {
}
}, []);

const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;

const label = useMemo(() => {
const generated = reasoningLabel?.trim();
if (generated) {
Expand Down Expand Up @@ -332,7 +350,10 @@ 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(
() => displayReasoning(reasoning, isStreaming),
[reasoning, isStreaming],
);

const handleToggle = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,4 +116,40 @@ 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(
<ReasoningCompact
reasoning={`Checking the manifest.\n${encoded}\nRolling back.`}
label="Thoughts"
showThinking
/>,
);

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));
});

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(
<ReasoningCompact
reasoning={`Checking the manifest. ${prefix}`}
label="Thinking"
showThinking
isStreaming
/>,
);

const body = screen.getByRole('group', { hidden: true });
expect(body).toHaveTextContent('Checking the manifest.');
expect(body.textContent).not.toContain(prefix.slice(0, 32));
});
});
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="thinking-label" title={title}>
{label}
</div>
),
}));

describe('ReasoningMarker', () => {
it('shows the generated label for reasoning this view cannot show', () => {
render(<ReasoningMarker label="Planning the answer" />);

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(<ReasoningMarker encrypted label="Inspecting an encoded data snapshot" />);

const marker = screen.getByTestId('thinking-label');
expect(marker).toHaveTextContent('com_ui_reasoning_encrypted');
expect(marker).toHaveAttribute('title', 'com_ui_reasoning_encrypted_description');
});
});
14 changes: 9 additions & 5 deletions client/src/components/Chat/Messages/Content/ToolCallGroup.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,34 @@ describe('EditContentParts', () => {
]);
});

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(
<EditContentParts
content={mixedContent}
messageId={message.messageId}
isSubmitting={false}
enterEdit={jest.fn()}
siblingIdx={0}
setSiblingIdx={jest.fn()}
renderReadOnlyPart={renderReadOnlyPart}
/>,
);

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', () => {
const enterEdit = jest.fn();
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)}…`);
},
);

Expand Down Expand Up @@ -1706,3 +1708,63 @@ 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('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('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.')],
(key) => key,
[],
);
expect(activity.text).toBe('The resolver pins an older range.');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ jest.mock('../Parts', () => ({
AgentUpdate: () => <div data-testid="agent-update" />,
EmptyText: () => <div data-testid="empty-text" />,
Reasoning: () => <div data-testid="reasoning" />,
ReasoningMarker: ({ label }: { label?: string }) => (
<div data-testid="reasoning-marker">{label}</div>
ReasoningMarker: ({ label, encrypted }: { label?: string; encrypted?: boolean }) => (
<div data-testid="reasoning-marker" data-encrypted={String(encrypted === true)}>
{label}
</div>
),
Summary: () => <div data-testid="summary" />,
Text: ({ text }: { text?: string }) => <div data-testid="text">{text}</div>,
Expand Down Expand Up @@ -280,4 +282,34 @@ 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();
});

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: `<think>${encoded}</think>`,
} as TMessageContentParts);

expect(screen.getByTestId('reasoning-marker')).toHaveAttribute('data-encrypted', 'true');
expect(screen.queryByTestId('reasoning')).not.toBeInTheDocument();
});
});
Loading
Loading