🧠 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

This commit is contained in:
Danny Avila 2026-08-30 19:33:31 -04:00 committed by GitHub
parent fcae1025c0
commit 1e4cae07c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 168 additions and 62 deletions

View file

@ -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();

View file

@ -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}

View file

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

View file

@ -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=""

View file

@ -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],
);

View file

@ -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', () => {

View file

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

View file

@ -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;

View file

@ -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', () => {

View file

@ -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 {

View file

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

View file

@ -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({

View file

@ -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 () => {

View file

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

View file

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

View file

@ -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';