mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-16 18:01:03 +00:00
🧠 feat: Retain Subagent Reasoning Like Main Chat and Drop the Running Status Chip (#15379)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
Sync Helm Chart Tags / Ignore non-main push (push) Waiting to run
Sync Helm Chart Tags / Sync chart tags (push) Waiting to run
This commit is contained in:
parent
fcae1025c0
commit
1e4cae07c7
16 changed files with 168 additions and 62 deletions
|
|
@ -214,7 +214,7 @@ const base: ChildActivity = {
|
|||
};
|
||||
|
||||
describe('SubagentActivity', () => {
|
||||
it.each(['running', 'failed', 'cancelled'] as const)(
|
||||
it.each(['failed', 'cancelled'] as const)(
|
||||
'renders the %s lifecycle through the shared view',
|
||||
(status) => {
|
||||
render(<SubagentActivity activity={{ ...base, status }} />);
|
||||
|
|
@ -222,6 +222,11 @@ describe('SubagentActivity', () => {
|
|||
},
|
||||
);
|
||||
|
||||
it('shows no status chip while running, matching the main chat view', () => {
|
||||
render(<SubagentActivity activity={{ ...base, status: 'running' }} />);
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not repeat the completed lifecycle in the conversation body', () => {
|
||||
render(<SubagentActivity activity={base} />);
|
||||
|
||||
|
|
@ -393,7 +398,7 @@ describe('SubagentActivity', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_submitted')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_accepted')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_control_status_applied')).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import { CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-reac
|
|||
import type { TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { ChildActivity, ChildActivityItem } from './adapters';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import { isAbnormalTerminalStatus, subagentStatusIcon, subagentStatusLabelKey } from './status';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import { subagentStatusIcon, subagentStatusLabelKey } from './status';
|
||||
import Container from '~/components/Chat/Messages/Content/Container';
|
||||
import { EmptyText } from '~/components/Chat/Messages/Content/Parts';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
|
|
@ -295,6 +295,7 @@ export const hasTruncatedActivityDetails = (activity: ChildActivity): boolean =>
|
|||
activity.items.some(
|
||||
(item) =>
|
||||
(item.type === 'writing' && item.textTruncated === true) ||
|
||||
(item.type === 'reasoning' && item.textTruncated === true) ||
|
||||
(item.type === 'activity_label' && item.labelTruncated === true) ||
|
||||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
|
||||
);
|
||||
|
|
@ -410,12 +411,11 @@ export default function SubagentActivity({
|
|||
showPrompt?: boolean;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) {
|
||||
const statusHeader =
|
||||
activity.status === 'completed' ? null : (
|
||||
<div className="shrink-0 border-b border-border-light px-4 py-2">
|
||||
<SubagentStatus activity={activity} />
|
||||
</div>
|
||||
);
|
||||
const statusHeader = isAbnormalTerminalStatus(activity.status) ? (
|
||||
<div className="shrink-0 border-b border-border-light px-4 py-2">
|
||||
<SubagentStatus activity={activity} />
|
||||
</div>
|
||||
) : null;
|
||||
const content = (
|
||||
<SubagentActivityContent
|
||||
activity={activity}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ describe('SubagentConversation', () => {
|
|||
expect(screen.getByText('search')).toBeInTheDocument();
|
||||
expect(screen.getByText('The release is ready.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_completed')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_status_running')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('thinking-cursor')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.message-render')).toHaveLength(3);
|
||||
expect(container.querySelectorAll('.user-turn')).toHaveLength(1);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { isAbnormalTerminalStatus } from './status';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
|
@ -207,7 +208,9 @@ function ChildMessage({
|
|||
}
|
||||
label={label}
|
||||
footer={
|
||||
turn.activity.status === 'completed' ? null : <SubagentStatus activity={turn.activity} />
|
||||
isAbnormalTerminalStatus(turn.activity.status) ? (
|
||||
<SubagentStatus activity={turn.activity} />
|
||||
) : null
|
||||
}
|
||||
ariaLabel={label}
|
||||
headerPrefix=""
|
||||
|
|
|
|||
|
|
@ -702,7 +702,6 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
initialProgress: selection.durable == null ? selection.initialProgress : 0,
|
||||
isSubmitting: selection.durable == null ? selection.isSubmitting : detachedLiveSubmitting,
|
||||
runStepStatus: selection.durable == null ? selection.runStepStatus : undefined,
|
||||
reasoningVisibility: selection.durable == null ? 'visible' : 'marker',
|
||||
}),
|
||||
[detachedLiveSubmitting, foregroundTitle, progress, selection],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -438,24 +438,40 @@ describe('child activity adapters', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('redacts detached live reasoning while retaining its activity marker', () => {
|
||||
it('keeps live reasoning text like the main chat view', () => {
|
||||
const activity = adaptLivePersistedActivity({
|
||||
title: 'researcher',
|
||||
progress: null,
|
||||
persistedContent: [
|
||||
{ type: ContentTypes.THINK, think: 'private live reasoning' },
|
||||
{ type: ContentTypes.THINK, think: 'visible live reasoning' },
|
||||
{ type: ContentTypes.TEXT, text: 'Visible answer.' },
|
||||
] as TMessageContentParts[],
|
||||
initialProgress: 0,
|
||||
isSubmitting: true,
|
||||
});
|
||||
|
||||
expect(activity.items).toEqual([
|
||||
{ type: 'reasoning', text: 'visible live reasoning' },
|
||||
{ type: 'writing', text: 'Visible answer.' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes a pre-retention redaction marker back to a textless reasoning item', () => {
|
||||
const activity = adaptLivePersistedActivity({
|
||||
title: 'researcher',
|
||||
progress: null,
|
||||
persistedContent: [
|
||||
{ type: ContentTypes.THINK, think: '…' },
|
||||
{ type: ContentTypes.TEXT, text: 'Visible answer.' },
|
||||
] as TMessageContentParts[],
|
||||
initialProgress: 0,
|
||||
isSubmitting: true,
|
||||
reasoningVisibility: 'marker',
|
||||
});
|
||||
|
||||
expect(activity.items).toEqual([
|
||||
{ type: 'reasoning' },
|
||||
{ type: 'writing', text: 'Visible answer.' },
|
||||
]);
|
||||
expect(JSON.stringify(activity)).not.toContain('private live reasoning');
|
||||
});
|
||||
|
||||
it('keeps an empty-output approval pending', () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
TMessageContentParts,
|
||||
} from 'librechat-data-provider';
|
||||
import type { SubagentProgress } from '~/store/subagents';
|
||||
import { REDACTED_REASONING_MARKER } from '~/store/subagents';
|
||||
|
||||
export type ChildActivityItem =
|
||||
| {
|
||||
|
|
@ -22,6 +23,7 @@ export type ChildActivityItem =
|
|||
type: 'reasoning';
|
||||
text?: string;
|
||||
label?: string;
|
||||
textTruncated?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'tool';
|
||||
|
|
@ -82,7 +84,6 @@ type ContentToolCall = {
|
|||
|
||||
const contentPartsToActivity = (
|
||||
parts: TMessageContentParts[],
|
||||
reasoningVisibility: 'visible' | 'marker',
|
||||
approvalVisibility: 'visible' | 'hidden',
|
||||
): ChildActivityItem[] =>
|
||||
parts.flatMap((part, index): ChildActivityItem[] => {
|
||||
|
|
@ -100,12 +101,15 @@ const contentPartsToActivity = (
|
|||
];
|
||||
}
|
||||
if (part.type === ContentTypes.THINK) {
|
||||
const think = (part as { think?: string }).think ?? '';
|
||||
/** Streams from a pre-retention server substitute this marker for the
|
||||
* withheld reasoning text; normalize it back to a textless item so the
|
||||
* shared marker row renders instead of a disclosure containing "…". */
|
||||
const text = think === REDACTED_REASONING_MARKER ? '' : think;
|
||||
return [
|
||||
{
|
||||
type: 'reasoning',
|
||||
...(reasoningVisibility === 'visible' ? { text: (part as { think: string }).think } : {}),
|
||||
/** The generated reasoning label is display-safe orientation, kept
|
||||
* even when the reasoning text itself stays server-private. */
|
||||
...(text === '' ? {} : { text }),
|
||||
...(typeof (part as { reasoning_label?: string }).reasoning_label === 'string'
|
||||
? { label: (part as { reasoning_label: string }).reasoning_label }
|
||||
: {}),
|
||||
|
|
@ -277,15 +281,13 @@ export function adaptLivePersistedActivity(input: {
|
|||
isSubmitting: boolean;
|
||||
runStepStatus?: PartMetadata['runStepStatus'];
|
||||
isDetached?: boolean;
|
||||
reasoningVisibility?: 'visible' | 'marker';
|
||||
approvalVisibility?: 'visible' | 'hidden';
|
||||
}): ChildActivity {
|
||||
const persisted = input.persistedContent ?? [];
|
||||
const live = (input.progress?.contentParts ?? []) as TMessageContentParts[];
|
||||
const reasoningVisibility = input.reasoningVisibility ?? 'visible';
|
||||
const approvalVisibility = input.approvalVisibility ?? 'visible';
|
||||
const persistedItems = contentPartsToActivity(persisted, reasoningVisibility, approvalVisibility);
|
||||
const liveItems = contentPartsToActivity(live, reasoningVisibility, approvalVisibility);
|
||||
const persistedItems = contentPartsToActivity(persisted, approvalVisibility);
|
||||
const liveItems = contentPartsToActivity(live, approvalVisibility);
|
||||
let items = persistedItems.length > 0 ? persistedItems : liveItems;
|
||||
if (input.isDetached === true && input.progress?.coverage === 'suffix') {
|
||||
items = mergePersistedAndLiveActivity(persistedItems, liveItems);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { AlertCircle, CheckCircle2, Clock3, XCircle } from 'lucide-react';
|
||||
import type { SubagentThreadStatus } from 'librechat-data-provider';
|
||||
|
||||
/** Only abnormal endings earn a status chip — main chat conveys an in-flight
|
||||
* run through its streaming content and cursor, never a "running" label. */
|
||||
export const isAbnormalTerminalStatus = (status: SubagentThreadStatus): boolean =>
|
||||
status === 'failed' || status === 'interrupted' || status === 'cancelled';
|
||||
|
||||
export const subagentStatusIcon = (status: SubagentThreadStatus) => {
|
||||
if (status === 'completed') return CheckCircle2;
|
||||
if (status === 'failed' || status === 'interrupted') return AlertCircle;
|
||||
|
|
|
|||
|
|
@ -234,14 +234,34 @@ describe('reduceSubagentProgress', () => {
|
|||
expect(progress?.lastActivitySequence).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves a reasoning activity marker without retaining private reasoning text', () => {
|
||||
it('keeps detached reasoning text like the parent delivery path', () => {
|
||||
const progress = reduceSubagentProgress(
|
||||
null,
|
||||
[
|
||||
update({
|
||||
activitySequence: 0,
|
||||
phase: 'reasoning_delta',
|
||||
data: { delta: { content: [{ type: ContentTypes.THINK, think: 'private' }] } },
|
||||
data: { delta: { content: [{ type: ContentTypes.THINK, think: 'Visible reasoning' }] } },
|
||||
label: 'Reasoning',
|
||||
}),
|
||||
],
|
||||
'detached',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(progress?.contentParts).toEqual([
|
||||
{ type: ContentTypes.THINK, think: 'Visible reasoning' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('substitutes a marker for a pre-retention reasoning event that stripped its data', () => {
|
||||
const progress = reduceSubagentProgress(
|
||||
null,
|
||||
[
|
||||
update({
|
||||
activitySequence: 0,
|
||||
phase: 'reasoning_delta',
|
||||
data: undefined,
|
||||
label: 'Reasoning',
|
||||
}),
|
||||
],
|
||||
|
|
@ -253,7 +273,6 @@ describe('reduceSubagentProgress', () => {
|
|||
expect(progress?.tickerState.lines).toEqual([
|
||||
expect.objectContaining({ kind: 'reasoning', body: '…' }),
|
||||
]);
|
||||
expect(JSON.stringify(progress)).not.toContain('private');
|
||||
});
|
||||
|
||||
it('preserves visible reasoning on the authoritative parent delivery path', () => {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,9 @@ const MAX_LIVE_ACTIVITY_ITEMS = 100;
|
|||
const MAX_LIVE_ACTIVITY_BYTES = 64 * 1024;
|
||||
const MAX_SINGLE_ACTIVITY_ENCODED_BYTES = MAX_LIVE_ACTIVITY_BYTES - 2;
|
||||
const MAX_SINGLE_ACTIVITY_TEXT_BYTES = 60 * 1024;
|
||||
const REDACTED_REASONING_MARKER = '…';
|
||||
/** Substituted for reasoning text by pre-retention servers; current servers
|
||||
* transport the bounded reasoning text itself. */
|
||||
export const REDACTED_REASONING_MARKER = '…';
|
||||
|
||||
const encodedBytes = (value: unknown): number =>
|
||||
new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
||||
|
|
@ -599,10 +601,6 @@ export function reduceSubagentProgress(
|
|||
if (firstSequence != null) expected = firstSequence;
|
||||
}
|
||||
|
||||
const sanitizeSequencedEvent = (event: SubagentUpdateEvent): SubagentUpdateEvent =>
|
||||
source === 'detached' && event.phase === 'reasoning_delta'
|
||||
? { ...event, data: undefined }
|
||||
: event;
|
||||
const drainPending = () => {
|
||||
pending.sort((left, right) => (left.activitySequence ?? 0) - (right.activitySequence ?? 0));
|
||||
while (pending[0]?.activitySequence === expected) {
|
||||
|
|
@ -621,16 +619,15 @@ export function reduceSubagentProgress(
|
|||
if (key != null && seen.has(key)) continue;
|
||||
if (validActivitySequence(sequence)) {
|
||||
if (sequence < expected || pendingSequences.has(sequence)) continue;
|
||||
const pendingEvent = sanitizeSequencedEvent(event);
|
||||
if (sequence === expected) {
|
||||
directEvents.push(pendingEvent);
|
||||
directEvents.push(event);
|
||||
expected += 1;
|
||||
drainPending();
|
||||
} else if (
|
||||
pending.length < MAX_PENDING_SEQUENCE_EVENTS &&
|
||||
encodedBytes([...pending, pendingEvent]) <= MAX_PENDING_SEQUENCE_BYTES
|
||||
encodedBytes([...pending, event]) <= MAX_PENDING_SEQUENCE_BYTES
|
||||
) {
|
||||
pending.push(pendingEvent);
|
||||
pending.push(event);
|
||||
pendingSequences.add(sequence);
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe('durable subagent activity projection', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps visible text and tool lifecycle while dropping private metadata and reasoning text', () => {
|
||||
it('keeps visible text, reasoning text, and tool lifecycle while dropping private metadata', () => {
|
||||
const projection = projectSubagentActivity(
|
||||
JSON.stringify([
|
||||
{
|
||||
|
|
@ -101,7 +101,7 @@ describe('durable subagent activity projection', () => {
|
|||
|
||||
expect(projection).toEqual({
|
||||
activity: [
|
||||
{ type: 'reasoning' },
|
||||
{ type: 'reasoning', text: 'private chain of thought' },
|
||||
{ type: 'writing', text: 'I will check.' },
|
||||
{
|
||||
type: 'tool',
|
||||
|
|
@ -114,11 +114,34 @@ describe('durable subagent activity projection', () => {
|
|||
],
|
||||
truncated: false,
|
||||
});
|
||||
expect(JSON.stringify(projection)).not.toContain('private chain of thought');
|
||||
expect(JSON.stringify(projection)).not.toContain('private-request');
|
||||
expect(JSON.stringify(projection)).not.toContain('never expose');
|
||||
});
|
||||
|
||||
it('bounds oversized reasoning text and marks the truncation', () => {
|
||||
const projection = projectSubagentActivity(
|
||||
JSON.stringify([
|
||||
{
|
||||
type: 'ai',
|
||||
data: {
|
||||
content: [
|
||||
{
|
||||
type: 'reasoning',
|
||||
reasoning: 'r'.repeat(SUBAGENT_ACTIVITY_LIMITS.textBytes + 1024),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const [item] = projection.activity;
|
||||
expect(item).toEqual(expect.objectContaining({ type: 'reasoning', textTruncated: true }));
|
||||
expect(Buffer.byteLength((item as { text?: string }).text ?? '', 'utf8')).toBeLessThanOrEqual(
|
||||
SUBAGENT_ACTIVITY_LIMITS.textBytes,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed on invalid input and bounds adversarial activity', () => {
|
||||
expect(projectSubagentActivity('{')).toEqual({ activity: [], truncated: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -93,8 +93,9 @@ const shrinkStringField = <T extends SubagentActivityItem>(
|
|||
|
||||
const fitNewestItemToSerializedBudget = (item: SubagentActivityItem): SubagentActivityItem => {
|
||||
if (serializedBytes([item]) <= MAX_ACTIVITY_BYTES) return item;
|
||||
if (item.type === 'writing') return shrinkStringField(item, 'text', 'textTruncated');
|
||||
if (item.type === 'reasoning') return item;
|
||||
if (item.type === 'writing' || item.type === 'reasoning') {
|
||||
return shrinkStringField(item, 'text', 'textTruncated');
|
||||
}
|
||||
if (item.type === 'activity_label') {
|
||||
const withoutAssociations = { ...item, toolCallIds: undefined, agentIds: undefined };
|
||||
return shrinkStringField(withoutAssociations, 'label', 'labelTruncated');
|
||||
|
|
@ -173,7 +174,17 @@ export function projectPersistedMessageActivity(
|
|||
},
|
||||
];
|
||||
}
|
||||
if (candidate.type === 'reasoning') return [{ type: 'reasoning' }];
|
||||
if (candidate.type === 'reasoning') {
|
||||
return [
|
||||
{
|
||||
type: 'reasoning',
|
||||
...(typeof candidate.text === 'string' && candidate.text !== ''
|
||||
? { text: candidate.text }
|
||||
: {}),
|
||||
...(candidate.textTruncated === true ? { textTruncated: true } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (candidate.type === 'activity_label') {
|
||||
if (typeof candidate.label !== 'string') {
|
||||
truncated = true;
|
||||
|
|
@ -251,21 +262,34 @@ export function projectPersistedMessageActivityJson(
|
|||
}
|
||||
}
|
||||
|
||||
const visibleContent = (value: unknown): { text: string; hasReasoning: boolean } => {
|
||||
if (typeof value === 'string') return { text: value, hasReasoning: false };
|
||||
if (!Array.isArray(value)) return { text: '', hasReasoning: false };
|
||||
const reasoningBlockText = (block: Record<string, unknown>): string => {
|
||||
if (typeof block.reasoning === 'string') return block.reasoning;
|
||||
if (typeof block.thinking === 'string') return block.thinking;
|
||||
if (typeof block.text === 'string') return block.text;
|
||||
return '';
|
||||
};
|
||||
|
||||
const visibleContent = (
|
||||
value: unknown,
|
||||
): { text: string; hasReasoning: boolean; reasoning: string } => {
|
||||
if (typeof value === 'string') return { text: value, hasReasoning: false, reasoning: '' };
|
||||
if (!Array.isArray(value)) return { text: '', hasReasoning: false, reasoning: '' };
|
||||
const text: string[] = [];
|
||||
const reasoning: string[] = [];
|
||||
let hasReasoning = false;
|
||||
for (const block of value) {
|
||||
if (!isRecord(block) || typeof block.type !== 'string') continue;
|
||||
if ((block.type === 'text' || block.type === 'text-plain') && typeof block.text === 'string') {
|
||||
text.push(block.text);
|
||||
} else if (block.type === 'reasoning' || block.type === 'thinking') {
|
||||
// Preserve the user-visible lifecycle marker, never the model's hidden reasoning payload.
|
||||
/** The same user reads this exact reasoning in the main chat view, so the
|
||||
* bounded projection keeps its text rather than only a lifecycle marker. */
|
||||
hasReasoning = true;
|
||||
const blockText = reasoningBlockText(block);
|
||||
if (blockText !== '') reasoning.push(blockText);
|
||||
}
|
||||
}
|
||||
return { text: text.join(''), hasReasoning };
|
||||
return { text: text.join(''), hasReasoning, reasoning: reasoning.join('\n\n') };
|
||||
};
|
||||
|
||||
const readToolCalls = (data: Record<string, unknown>): unknown[] => {
|
||||
|
|
@ -317,9 +341,9 @@ const uniqueToolActivityId = (
|
|||
|
||||
/**
|
||||
* Converts one server-private LangChain transcript into a bounded public
|
||||
* activity projection. Only visible text and declared tool calls/results are
|
||||
* retained; response metadata, artifacts, runtime fields, and reasoning text
|
||||
* are intentionally ignored.
|
||||
* activity projection. Visible text, bounded reasoning text, and declared
|
||||
* tool calls/results are retained; response metadata, artifacts, and runtime
|
||||
* fields are intentionally ignored.
|
||||
*/
|
||||
export function projectSubagentActivity(
|
||||
messagesJson: string | undefined,
|
||||
|
|
@ -380,7 +404,14 @@ export function projectSubagentActivity(
|
|||
const { data } = stored;
|
||||
if (stored.type === 'ai' || stored.type === 'assistant') {
|
||||
const content = visibleContent(data.content);
|
||||
if (content.hasReasoning) append({ type: 'reasoning' });
|
||||
if (content.hasReasoning) {
|
||||
const reasoning = truncateUtf8(content.reasoning, MAX_ACTIVITY_TEXT_BYTES);
|
||||
append({
|
||||
type: 'reasoning',
|
||||
...(reasoning.value === '' ? {} : { text: reasoning.value }),
|
||||
...(reasoning.truncated ? { textTruncated: true } : {}),
|
||||
});
|
||||
}
|
||||
if (content.text !== '') {
|
||||
const text = truncateUtf8(content.text, MAX_ACTIVITY_TEXT_BYTES);
|
||||
append({
|
||||
|
|
|
|||
|
|
@ -364,17 +364,22 @@ describe('detached subagent activity stream', () => {
|
|||
expect(Buffer.byteLength(JSON.stringify(envelope), 'utf8')).toBeLessThanOrEqual(64 * 1024);
|
||||
});
|
||||
|
||||
it('never transports hidden reasoning text to the detached panel', async () => {
|
||||
it('transports bounded reasoning deltas to the detached panel like other phases', async () => {
|
||||
const transport = new TestTransport();
|
||||
const stream = new SubagentActivityStream(transport);
|
||||
|
||||
await stream.publish(
|
||||
'child-thread',
|
||||
'task-1',
|
||||
update({ phase: 'reasoning_delta', data: { delta: { content: [{ think: 'secret' }] } } }),
|
||||
update({
|
||||
phase: 'reasoning_delta',
|
||||
data: { delta: { content: [{ think: 'Visible reasoning' }] } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect((transport.emitted[0]?.event as SubagentActivityEnvelope).data.data).toBeUndefined();
|
||||
expect((transport.emitted[0]?.event as SubagentActivityEnvelope).data.data).toEqual({
|
||||
delta: { content: [{ think: 'Visible reasoning' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('delivers terminal state before the subscriber releases its task stream', async () => {
|
||||
|
|
|
|||
|
|
@ -135,12 +135,10 @@ export const boundSubagentActivityUpdate = (
|
|||
'utf8',
|
||||
);
|
||||
}
|
||||
/** Detached durable views expose only a reasoning marker. Keep that same boundary
|
||||
* on the live path rather than transporting hidden reasoning text to the browser. */
|
||||
const data =
|
||||
event.phase === 'reasoning_delta'
|
||||
? undefined
|
||||
: boundedData(event.data, Math.max(0, MAX_EVENT_BYTES - baseBytes - 32));
|
||||
/** Reasoning deltas ride the detached stream like every other phase — the
|
||||
* same user reads this reasoning in the main chat view, and the durable
|
||||
* projection now retains its bounded text as well. */
|
||||
const data = boundedData(event.data, Math.max(0, MAX_EVENT_BYTES - baseBytes - 32));
|
||||
return data == null ? base : { ...base, data };
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ describe('subagent thread parent-scoped view', () => {
|
|||
);
|
||||
const view = json.mock.calls[0][0];
|
||||
expect(view.activity).toEqual([
|
||||
{ type: 'reasoning' },
|
||||
{ type: 'reasoning', text: 'private thought' },
|
||||
expect.objectContaining({
|
||||
type: 'tool',
|
||||
toolCallId: 'inner-1',
|
||||
|
|
@ -327,7 +327,6 @@ describe('subagent thread parent-scoped view', () => {
|
|||
}),
|
||||
{ type: 'writing', text: 'Final answer.' },
|
||||
]);
|
||||
expect(JSON.stringify(view)).not.toContain('private thought');
|
||||
expect(JSON.stringify(view)).not.toContain('response_metadata');
|
||||
expect(view.messages[0]).not.toHaveProperty('subagentTranscript');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,6 +54,10 @@ export type SubagentActivityItem =
|
|||
}
|
||||
| {
|
||||
type: 'reasoning';
|
||||
/** Bounded user-visible reasoning text; absent on projections persisted
|
||||
* before reasoning retention (rendered as a marker). */
|
||||
text?: string;
|
||||
textTruncated?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'activity_label';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue