diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 097da840e30..25ae7ab6022 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -576,7 +576,7 @@ describe('OpenAIChatCompletionController', () => { ); it.each([true, false])( - 'publishes only the accepted client call after a model claim (stream=%s)', + 'publishes accepted client calls after text and a model claim (stream=%s)', async (streaming) => { const api = require('@librechat/api'); const actual = jest.requireActual('@librechat/api'); @@ -608,6 +608,9 @@ describe('OpenAIChatCompletionController', () => { agentId: 'agent-123', messageId: 'claimed-message', }); + await handlers.on_message_delta.handle('on_message_delta', { + delta: { content: [{ type: 'text', text: 'Checking the weather.' }] }, + }); await handlers.on_model_response.handle('on_model_response', { type: 'model_response', id: 'accepted-client', @@ -627,6 +630,9 @@ describe('OpenAIChatCompletionController', () => { .map(([frame]) => frame) .filter((frame) => frame !== 'data: [DONE]\n\n') .map((frame) => JSON.parse(frame.slice(6))); + expect( + frames.some((frame) => frame.choices[0].delta.content === 'Checking the weather.'), + ).toBe(true); const calls = frames.flatMap((frame) => frame.choices[0].delta.tool_calls ?? []); expect(calls).toEqual([ { index: 0, id: 'a', type: 'function', function: { name: 'get_time', arguments: '' } }, @@ -642,6 +648,7 @@ describe('OpenAIChatCompletionController', () => { expect(frames.at(-1).choices[0].finish_reason).toBe('tool_calls'); } else { const choice = res.json.mock.calls[0][0].choices[0]; + expect(choice.message.content).toBe('Checking the weather.'); expect(choice.message.tool_calls).toEqual([ { id: 'a', @@ -909,6 +916,7 @@ describe('OpenAIChatCompletionController', () => { expect.anything(), expect.anything(), mockCompletionUsage, + true, ); }); @@ -920,7 +928,12 @@ describe('OpenAIChatCompletionController', () => { await OpenAIChatCompletionController(req, res); - expect(sendFinalChunk).toHaveBeenCalledWith(expect.anything(), 'stop', mockCompletionUsage); + expect(sendFinalChunk).toHaveBeenCalledWith( + expect.anything(), + 'stop', + mockCompletionUsage, + true, + ); }); describe('content filtering', () => { diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 1910cab8ef4..6cb1e800531 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -1180,7 +1180,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { // Finalize response const duration = Date.now() - requestStartTime; if (isStreaming) { - sendFinalChunk(handlerConfig, 'stop', usage); + sendFinalChunk(handlerConfig, 'stop', usage, true); res.end(); logger.debug(`[OpenAI API] Response ${responseId} completed in ${duration}ms (streaming)`); @@ -1216,6 +1216,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { aggregator.getReasoning(), aggregator.toolCalls, usage, + true, ); res.json(response); logger.debug( diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index ce157640fc5..d67c0f03641 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -33,9 +33,9 @@ async function invalidateCodeEnvironmentConfigCache(tenantId) { await getCodeEnvironmentRegistry().invalidateAccessibleConfigurations(tenantId); } -const loadBaseConfig = async () => { +const loadBaseConfig = async (mode) => { /** @type {TCustomConfig} */ - const config = (await loadCustomConfig()) ?? {}; + const config = (await loadCustomConfig(true, { mode })) ?? {}; /** @type {Record} */ const systemTools = loadAndFormatTools({ adminFilter: config.filteredTools, diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 45cec141607..c253c4f14b3 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,237 +1,11 @@ const path = require('path'); -const axios = require('axios'); -const yaml = require('js-yaml'); -const keyBy = require('lodash/keyBy'); -const { loadYaml, redactConfigSecretMaps } = require('@librechat/api'); -const { Providers } = require('@librechat/agents'); -const { logger } = require('@librechat/data-schemas'); -const { - configSchema, - paramSettings, - EModelEndpoint, - EImageOutputType, - setMaxSubagents, - agentParamSettings, - validateSettingDefinitions, -} = require('librechat-data-provider'); +const { loadYaml, redactConfigSecretMaps, createCustomConfigLoader } = require('@librechat/api'); const projectRoot = path.resolve(__dirname, '..', '..', '..', '..'); const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml'); -let i = 0; - -const OPENROUTER_PROMPT_CACHE_DEFAULT = { - key: 'promptCache', - default: true, -}; - -function includesOpenRouter(value) { - return typeof value === 'string' && value.toLowerCase().includes(Providers.OPENROUTER); -} - -function isOpenRouterEndpoint(endpoint) { - return includesOpenRouter(endpoint.name) || includesOpenRouter(endpoint.baseURL); -} - -function shouldPreserveCustomParams(customParams) { - const defaultEndpoint = customParams?.defaultParamsEndpoint; - return ( - defaultEndpoint && defaultEndpoint !== 'custom' && defaultEndpoint !== Providers.OPENROUTER - ); -} - -function addOpenRouterDefaults(endpoint) { - if (!isOpenRouterEndpoint(endpoint)) { - return; - } - - if (shouldPreserveCustomParams(endpoint.customParams)) { - return; - } - - const customParams = endpoint.customParams ?? {}; - const paramDefinitions = customParams.paramDefinitions ?? []; - const hasPromptCache = paramDefinitions.some((param) => param.key === 'promptCache'); - - endpoint.customParams = { - ...customParams, - defaultParamsEndpoint: Providers.OPENROUTER, - paramDefinitions: hasPromptCache - ? paramDefinitions - : [...paramDefinitions, OPENROUTER_PROMPT_CACHE_DEFAULT], - }; -} - -/** - * Load custom configuration files and caches the object if the `cache` field at root is true. - * Validation via parsing the config file with the config schema. - * @function loadCustomConfig - * @returns {Promise} A promise that resolves to null or the custom config object. - * */ -async function loadCustomConfig(printConfig = true) { - // Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath - const configPath = process.env.CONFIG_PATH || defaultConfigPath; - - let customConfig; - - if (/^https?:\/\//.test(configPath)) { - try { - const response = await axios.get(configPath); - customConfig = response.data; - } catch (error) { - i === 0 && logger.error(`Failed to fetch the remote config file from ${configPath}`, error); - i === 0 && i++; - return null; - } - } else { - customConfig = loadYaml(configPath); - if (!customConfig) { - i === 0 && - logger.info( - 'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://www.librechat.ai/docs/configuration/librechat_yaml\n\n', - ); - i === 0 && i++; - return null; - } - - if (customConfig.reason || customConfig.stack) { - i === 0 && logger.error('Config file YAML format is invalid:', customConfig); - i === 0 && i++; - return null; - } - } - - if (typeof customConfig === 'string') { - try { - customConfig = yaml.load(customConfig); - } catch (parseError) { - i === 0 && logger.info(`Failed to parse the YAML config from ${configPath}`, parseError); - i === 0 && i++; - return null; - } - } - - // Applied before parsing so specs validated in the same pass (whose subagent - // presets share the cap) check against the configured limit. Invalid values - // are ignored here and rejected by the schema parse below. - setMaxSubagents(customConfig?.endpoints?.[EModelEndpoint.agents]?.maxSubagents); - - const result = configSchema.strict().safeParse(customConfig); - if (result?.error?.errors?.some((err) => err?.path && err.path?.includes('imageOutputType'))) { - throw new Error( - ` -Please specify a correct \`imageOutputType\` value (case-sensitive). - - The available options are: - - ${EImageOutputType.JPEG} - - ${EImageOutputType.PNG} - - ${EImageOutputType.WEBP} - - Refer to the latest config file guide for more information: - https://www.librechat.ai/docs/configuration/librechat_yaml`, - ); - } - if (!result.success) { - let errorMessage = `Invalid custom config file at ${configPath}: -${JSON.stringify(result.error, null, 2)}`; - - logger.error(errorMessage); - const speechError = result.error.errors.find( - (err) => - err.code === 'unrecognized_keys' && - (err.message?.includes('stt') || err.message?.includes('tts')), - ); - - if (speechError) { - logger.warn(` -The Speech-to-text and Text-to-speech configuration format has recently changed. -If you're getting this error, please refer to the latest documentation: - -https://www.librechat.ai/docs/configuration/stt_tts`); - } - - if (process.env.CONFIG_BYPASS_VALIDATION === 'true') { - logger.warn( - 'CONFIG_BYPASS_VALIDATION is enabled. Continuing with default configuration despite validation errors.', - ); - return null; - } - - logger.error( - 'Exiting due to invalid configuration. Set CONFIG_BYPASS_VALIDATION=true to bypass this check.', - ); - process.exit(1); - } else { - if (printConfig) { - // Masks map-valued secrets (e.g. `langfuse.headers`) so literal gateway - // credentials are not copied into application logs on every startup. - const loggableConfig = redactConfigSecretMaps(customConfig); - logger.info('Custom config file loaded:'); - logger.info(JSON.stringify(loggableConfig, null, 2)); - logger.debug('Custom config:', loggableConfig); - } - } - - (customConfig.endpoints?.custom ?? []).forEach(addOpenRouterDefaults); - - (customConfig.endpoints?.custom ?? []) - .filter((endpoint) => endpoint.customParams) - .forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams)); - - if (result.data.modelSpecs) { - customConfig.modelSpecs = result.data.modelSpecs; - } - - return customConfig; -} - -// Validate and fill out missing values for custom parameters -function parseCustomParams(endpointName, customParams) { - const paramEndpoint = customParams.defaultParamsEndpoint ?? 'custom'; - customParams.defaultParamsEndpoint = paramEndpoint; - customParams.paramDefinitions = customParams.paramDefinitions || []; - - // Checks if `defaultParamsEndpoint` is a key in `paramSettings`. - const validEndpoints = new Set([ - ...Object.keys(paramSettings), - ...Object.keys(agentParamSettings), - ]); - if (!validEndpoints.has(paramEndpoint)) { - throw new Error( - `defaultParamsEndpoint of "${endpointName}" endpoint is invalid. ` + - `Valid options are ${Array.from(validEndpoints).join(', ')}`, - ); - } - - // creates default param maps - const regularParams = paramSettings[paramEndpoint] ?? []; - const agentParams = agentParamSettings[paramEndpoint] ?? []; - const defaultParams = regularParams.concat(agentParams); - const defaultParamsMap = keyBy(defaultParams, 'key'); - - // TODO: Remove this check once we support new parameters not part of default parameters. - // Checks if every key in `paramDefinitions` is valid. - const validKeys = new Set(Object.keys(defaultParamsMap)); - const paramKeys = customParams.paramDefinitions.map((param) => param.key); - if (paramKeys.some((key) => !validKeys.has(key))) { - throw new Error( - `paramDefinitions of "${endpointName}" endpoint contains invalid key(s). ` + - `Valid parameter keys are ${Array.from(validKeys).join(', ')}`, - ); - } - - // Fill out missing values for custom param definitions - customParams.paramDefinitions = customParams.paramDefinitions.map((param) => { - return { ...defaultParamsMap[param.key], ...param, optionType: 'custom' }; - }); - - try { - validateSettingDefinitions(customParams.paramDefinitions); - } catch (e) { - throw new Error( - `Custom parameter definitions for "${endpointName}" endpoint is malformed: ${e.message}`, - ); - } -} - -module.exports = loadCustomConfig; +module.exports = createCustomConfigLoader({ + loadLocal: loadYaml, + defaultConfigPath, + redactConfig: redactConfigSecretMaps, +}); diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index ca767a56006..f4e827590fa 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -147,6 +147,57 @@ describe('loadCustomConfig', () => { ); }); + it('does not exit when config validation fails during reload', async () => { + process.env.CONFIG_PATH = 'invalidConfig.yaml'; + loadYaml.mockReturnValueOnce({ invalidField: true }); + + await expect(loadCustomConfig(true, { mode: 'reload' })).rejects.toMatchObject({ + name: 'ConfigReloadError', + validationErrors: expect.any(Array), + }); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('rejects a reload when the local config file is missing', async () => { + process.env.CONFIG_PATH = 'missingConfig.yaml'; + loadYaml.mockReturnValueOnce(null); + + await expect(loadCustomConfig(true, { mode: 'reload' })).rejects.toMatchObject({ + name: 'ConfigReloadError', + }); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('rejects malformed local YAML during reload without exiting', async () => { + process.env.CONFIG_PATH = 'invalidConfig.yaml'; + loadYaml.mockReturnValueOnce(new Error('Malformed YAML')); + + await expect(loadCustomConfig(true, { mode: 'reload' })).rejects.toMatchObject({ + name: 'ConfigReloadError', + }); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('rejects malformed remote YAML during reload without exiting', async () => { + process.env.CONFIG_PATH = 'https://example.com/config.yaml'; + axios.get.mockResolvedValueOnce({ data: 'version: [unterminated' }); + + await expect(loadCustomConfig(true, { mode: 'reload' })).rejects.toMatchObject({ + name: 'ConfigReloadError', + }); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('rejects a reload when the remote config fetch fails', async () => { + process.env.CONFIG_PATH = 'https://example.com/config.yaml'; + axios.get.mockRejectedValueOnce(new Error('Network error')); + + await expect(loadCustomConfig(true, { mode: 'reload' })).rejects.toMatchObject({ + name: 'ConfigReloadError', + }); + expect(mockExit).not.toHaveBeenCalled(); + }); + it('should handle and return null on YAML parse error for a string response from remote', async () => { process.env.CONFIG_PATH = 'http://example.com/config.yaml'; process.env.CONFIG_BYPASS_VALIDATION = 'true'; @@ -466,6 +517,27 @@ describe('loadCustomConfig', () => { }); }); + it('treats an empty defaultParamsEndpoint as absent for OpenRouter', async () => { + const config = { + version: '1.0', + endpoints: { + custom: [ + { + name: 'OpenRouter', + apiKey: 'user_provided', + baseURL: 'https://openrouter.ai/api/v1', + models: { default: ['test-model'] }, + customParams: { defaultParamsEndpoint: '' }, + }, + ], + }, + }; + loadYaml.mockReturnValueOnce(config); + + const loaded = await loadCustomConfig(); + expect(loaded.endpoints.custom[0].customParams.defaultParamsEndpoint).toBe('openrouter'); + }); + it('preserves explicit OpenRouter promptCache defaults', async () => { const openRouterConfig = { version: '1.0', diff --git a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx index f86047be909..aa67c094f5b 100644 --- a/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ActivityPhaseGroup.tsx @@ -301,7 +301,10 @@ function LivePhaseHeader({ * and suppressed count together so neither can paint with the old value. */ const comboCount = showSandboxStartup ? 1 : activity.comboCount; const { source } = activity; - const line = useMemo(() => ({ text, source, comboCount }), [text, source, comboCount]); + const line = useMemo( + () => ({ text, source, comboCount, isBackgroundTaskCheck: activity.isBackgroundTaskCheck }), + [text, source, comboCount, activity.isBackgroundTaskCheck], + ); /** A thought's line is a finished sentence and holds for a beat; everything * else repaints at the ordinary cadence. */ const painted = useThrottledValue( @@ -316,7 +319,12 @@ function LivePhaseHeader({ * fail while a later one runs, and the line alone would never say so. The * hidden group header carries the same counts in the same words. */ const { failed, cancelled } = activity.outcome; - const combo = painted.comboCount > 1 ? `×${painted.comboCount}` : ''; + let combo = ''; + if (painted.comboCount > 1) { + combo = painted.isBackgroundTaskCheck + ? `· ${localize('com_ui_background_tasks_n_checks', { 0: String(painted.comboCount) })}` + : `×${painted.comboCount}`; + } const failedNote = failed > 0 ? localize(failed === 1 ? 'com_ui_one_action_failed' : 'com_ui_n_actions_failed', { diff --git a/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx b/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx new file mode 100644 index 00000000000..b46897aa917 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/BackgroundTaskCard.tsx @@ -0,0 +1,158 @@ +import type { BackgroundTaskStatus, BackgroundTaskView } from './Parts/background'; +import type { TranslationKeys } from '~/hooks'; +import { backgroundTaskMessageKey, backgroundTaskNoteKey } from './Parts/guidance'; +import { ToolIcon, getToolIconType, OutputRenderer } from './ToolOutput'; +import { formatBackgroundCodeOutput } from './Parts/background'; +import { parseToolName } from '~/utils/toolLabels'; +import { getToolDisplayLabel, cn } from '~/utils'; +import { useLocalize } from '~/hooks'; + +const STATUS: Record = { + dispatched: { label: 'com_ui_subagent_thread_status_dispatched', dot: 'bg-text-tertiary' }, + running: { label: 'com_ui_background_tasks_running', dot: 'bg-status-info' }, + stopping: { label: 'com_ui_background_tasks_stopping', dot: 'bg-status-warning' }, + accepted: { label: 'com_ui_background_tasks_control_queued', dot: 'bg-status-info' }, + claimed: { label: 'com_ui_background_tasks_result_claimed', dot: 'bg-text-tertiary' }, + not_running: { label: 'com_ui_background_tasks_not_running', dot: 'bg-status-warning' }, + control_not_found: { + label: 'com_ui_background_tasks_control_not_found', + dot: 'bg-status-warning', + }, + completed: { label: 'com_ui_background_tasks_completed', dot: 'bg-status-success' }, + error: { label: 'com_ui_failed', dot: 'bg-status-error' }, + failed: { label: 'com_ui_failed', dot: 'bg-status-error' }, + interrupted: { label: 'com_ui_subagent_thread_status_interrupted', dot: 'bg-status-error' }, + cancelled: { label: 'com_ui_cancelled', dot: 'bg-status-warning' }, +}; + +export default function BackgroundTaskCard({ + task, + mcpIconMap, + mcpServerNames, +}: { + task: BackgroundTaskView; + mcpIconMap?: Map; + mcpServerNames?: readonly string[]; +}) { + const localize = useLocalize(); + const isSubagent = task.toolName === 'subagent'; + const parsedName = parseToolName(task.toolName, mcpServerNames); + const serverName = parsedName.mcpServer; + let title = getToolDisplayLabel(task.toolName, localize, mcpServerNames); + if (isSubagent) { + title = localize('com_ui_background_tasks_subagent'); + } else if (serverName) { + title = parsedName.toolName; + } + const subtitle = task.subagentType || serverName; + const iconUrl = serverName ? mcpIconMap?.get(serverName) : undefined; + const iconType = getToolIconType(task.toolName); + const isCode = iconType === 'bash_tool' || iconType === 'execute_code'; + const state = STATUS[task.status]; + const failed = + task.status === 'error' || task.status === 'failed' || task.status === 'interrupted'; + const result = task.result?.trim() ? task.result : undefined; + const error = task.error?.trim() && task.error !== result ? task.error : undefined; + const noteKey = task.note ? backgroundTaskNoteKey(task.note) : undefined; + const messageKey = task.message ? backgroundTaskMessageKey(task.message) : undefined; + let delivery: string | undefined; + if (task.delivery === 'pending') { + delivery = localize('com_ui_background_tasks_result_pending'); + } else if (task.delivery === 'failed') { + delivery = localize('com_ui_background_tasks_result_undelivered'); + } + + return ( +
+
+ + + +
+
+ {title} +
+ {subtitle &&
{subtitle}
} + {delivery && ( +
+ {delivery} +
+ )} +
+ + +
+ {result && ( +
+
+ {localize(failed && !error ? 'com_ui_error' : 'com_ui_output')} +
+
+ +
+
+ )} + {error && ( +
+
+ {localize('com_ui_error')} +
+
+ +
+
+ )} + {!result && !error && task.resultClaimed && task.status !== 'claimed' && ( +

+ {localize('com_ui_background_tasks_result_claimed')} +

+ )} + {!result && !error && task.resultAvailable && !task.resultClaimed && ( +

+ {localize('com_ui_background_tasks_result_available')} +

+ )} + {noteKey &&

{localize(noteKey)}

} + {messageKey && messageKey !== noteKey && ( +

{localize(messageKey)}

+ )} + {!result && + !error && + !task.resultAvailable && + !task.resultClaimed && + task.result === '' && + !task.note && + !task.message && ( +

+ {localize('com_ui_background_tasks_no_output')} +

+ )} +
+ ); +} diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 21500f0b5e1..480f81b3406 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -31,6 +31,7 @@ import { getActivityLabelText, getPartKeyIndex, } from '~/utils'; +import BackgroundTaskCall from './Parts/BackgroundTaskCall'; import { getAskUserQuestionPart } from '~/utils/approval'; import AskUserQuestionCall from './AskUserQuestionCall'; import { isBashProgrammaticToolCall } from './routing'; @@ -302,6 +303,21 @@ const Part = memo(function Part({ onExpand={onToolExpand} /> ); + } else if (toolCall.name === Constants.CHECK_BACKGROUND_TASK) { + return ( + + ); } else if (toolCall.name === 'skill') { return ( ; + output?: string; + initialProgress?: number; + isSubmitting: boolean; + runStepStatus?: PartMetadata['runStepStatus']; + runStepDurationMs?: PartMetadata['runStepDurationMs']; + attachments?: TAttachment[]; + hideAttachments?: boolean; + onExpand?: () => void; + toolCallId?: string; +}) { + const localize = useLocalize(); + const [showRaw, setShowRaw] = useState(false); + const mcpIconMap = useMCPIconMap(); + const mcpServerNames = useMCPServerNames(); + const intent = useToolCallIntent(args); + const display = useMemo(() => parseBackgroundTaskOutput(output), [output]); + const input = useMemo(() => { + if (typeof args === 'string') { + return args; + } + try { + return JSON.stringify(args ?? {}) ?? ''; + } catch { + return ''; + } + }, [args]); + const hasParams = input.trim() !== '' && input.trim() !== '{}'; + const outcome = backgroundTaskOutcome(display); + const noticeText = + display?.kind === 'notice' + ? localize(backgroundTaskNoticeKey(display.status, display.message)) + : undefined; + const listGuidance = useMemo( + () => (display?.kind === 'list' ? backgroundListGuidanceKeys(display) : []), + [display], + ); + const { showCode, toggleCode, expandStyle, expandRef, phase, hasContent } = useToolCallState({ + initialProgress, + isSubmitting, + output, + hasInput: hasParams || (attachments?.length ?? 0) > 0, + onExpand, + runStepStatus, + extraError: outcome === 'failed', + extraCancelled: outcome === 'cancelled', + }); + const { shouldRenderBody, mountBody, handleTransitionEnd } = useLazyCollapseBody(showCode); + const handleToggle = useCallback(() => { + mountBody(); + toggleCode(); + }, [mountBody, toggleCode]); + + let finishedText = intent ?? localize('com_ui_background_tasks_checked'); + if (phase === 'cancelled') { + finishedText = + outcome === 'cancelled' && noticeText != null ? noticeText : localize('com_ui_cancelled'); + } else if (phase === 'failed' && outcome !== 'failed') { + finishedText = intent ?? localize('com_ui_background_tasks_checked'); + } else if (noticeText != null) { + finishedText = noticeText; + } else if (display?.kind === 'list' && outcome === 'failed') { + finishedText = localize('com_ui_background_tasks_incomplete'); + } + + let announcedText = finishedText; + if (phase === 'running') { + announcedText = localize('com_ui_background_tasks_checking'); + } else if (phase === 'failed' && (noticeText == null || outcome !== 'failed')) { + announcedText = + display?.kind === 'list' + ? localize('com_ui_background_tasks_incomplete') + : localize('com_ui_failed_subject', { 0: localize('com_ui_background_tasks') }); + } + + return ( + <> + + {announcedText} + +
+ } + hasInput={hasContent} + isExpanded={showCode} + /> +
+
+
+ {hasContent && shouldRenderBody && ( +
+ {display?.kind === 'task' && ( +
+ +
+ )} + {display?.kind === 'list' && ( +
+
+ {localize('com_ui_background_tasks')} + + {display.tasks.length} + +
+ {display.tasks.length > 0 ? ( +
    + {display.tasks.map((task) => ( +
  • + +
  • + ))} +
+ ) : ( +

+ {localize('com_ui_background_tasks_empty')} +

+ )} + {(display.partial || display.warning) && ( +

+ {localize('com_ui_background_tasks_incomplete')} +

+ )} + {listGuidance.map((key) => ( +

+ {localize(key)} +

+ ))} +
+ )} + {display?.kind === 'notice' && ( +

+ {noticeText} +

+ )} + {(display == null || hasParams) && ( +
+ +
+ )} + {display != null && ( +
setShowRaw(event.currentTarget.open)} + > + + {localize('com_ui_background_tasks_raw_details')} + + {showRaw && ( +
+ +
+ )} +
+ )} +
+ )} +
+
+ {!hideAttachments && attachments && attachments.length > 0 && ( + + )} + + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/BackgroundTaskCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/BackgroundTaskCall.test.tsx new file mode 100644 index 00000000000..45078e9cb4f --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/BackgroundTaskCall.test.tsx @@ -0,0 +1,429 @@ +import React from 'react'; +import { RecoilRoot } from 'recoil'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import BackgroundTaskCall from '../BackgroundTaskCall'; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useProgress: (progress: number) => progress, + useExpandCollapse: jest.requireActual('~/hooks/Messages/useExpandCollapse').default, + useLazyCollapseBody: jest.requireActual('~/hooks/Messages/useLazyCollapseBody').default, +})); + +jest.mock('~/hooks/MCP', () => ({ + useMCPIconMap: () => new Map(), + useMCPServerNames: () => [], +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getToolDisplayLabel: (name: string) => + (({ bash_tool: 'Code', web_search: 'Web Search' }) as Record)[name] ?? name, +})); + +jest.mock('../../ProgressText', () => ({ + __esModule: true, + default: ({ + phase, + inProgressText, + finishedText, + onClick, + hasInput, + isExpanded, + }: { + phase: string; + inProgressText: string; + finishedText: string; + onClick: () => void; + hasInput: boolean; + isExpanded: boolean; + }) => ( + + ), +})); + +jest.mock('../../ToolOutput', () => ({ + ToolIcon: ({ type }: { type: string }) => {type}, + getToolIconType: (name: string) => name, + getMCPServerName: () => '', + OutputRenderer: ({ text, copyText }: { text: string; copyText?: string }) => ( +
+      {text}
+    
+ ), + isError: () => false, +})); + +jest.mock('../../ToolCallInfo', () => ({ + __esModule: true, + default: ({ input, output }: { input: string; output?: string }) => ( +
+ ), +})); + +jest.mock('../Attachment', () => ({ + AttachmentGroup: () =>
, +})); + +const completed = { + background_task_id: 'bg-1', + tool: 'bash_tool', + status: 'completed', + result: 'stdout:\nchecked at=2026-09-27T00:11:09Z\n{"checks":[]}\n[exit code: 0]', +}; + +function renderCall( + output: string, + props: Partial> = {}, +) { + return render( + + + , + ); +} + +describe('BackgroundTaskCall', () => { + it('presents a completed code task as a native result without leaking its JSON envelope', () => { + renderCall(JSON.stringify(completed)); + const header = screen.getByTestId('task-header'); + expect(header).toHaveTextContent('com_ui_background_tasks_checked'); + expect(header).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByTestId('task-output')).not.toBeInTheDocument(); + + fireEvent.click(header); + const card = screen.getByTestId('background-task-card'); + expect(within(card).getByText('Code')).toBeInTheDocument(); + expect(within(card).getByText('com_ui_background_tasks_completed')).toBeInTheDocument(); + expect(within(card).getByTestId('tool-icon')).toHaveTextContent('bash_tool'); + expect(within(card).getByTestId('task-output')).toHaveTextContent('checked at=2026-09-27'); + expect(within(card).getByTestId('task-output').textContent).toContain('{\n "checks": []\n}'); + expect(within(card).getByTestId('task-output')).toHaveAttribute( + 'data-copy-text', + completed.result, + ); + expect(screen.getByTestId('tool-call-info')).not.toHaveAttribute('data-output'); + expect(screen.queryByText(/"background_task_id"/)).not.toBeInTheDocument(); + }); + + it('renders an empty list as an empty state and warns when discovery is incomplete', () => { + renderCall(JSON.stringify({ tasks: [], outstanding: 0, partial: true, warning: 'Try again.' })); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'failed'); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.getByText('com_ui_background_tasks_empty')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('com_ui_background_tasks_incomplete'); + expect(screen.queryByText('Try again.')).not.toBeInTheDocument(); + }); + + it('distinguishes a running task from a finished result pending automatic delivery', () => { + renderCall( + JSON.stringify({ + tasks: [ + { background_task_id: 'bg-1', tool: 'web_search', status: 'running' }, + { + background_task_id: 'bg-2', + tool: 'bash_tool', + status: 'completed', + delivery: 'pending', + result_available: true, + }, + ], + outstanding: 2, + }), + { args: '{}' }, + ); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.getByRole('list', { name: 'com_ui_background_tasks' })).toHaveAttribute( + 'tabindex', + '0', + ); + const [running, pending] = screen.getAllByTestId('background-task-card'); + expect(within(running).getByText('Web Search')).toBeInTheDocument(); + expect(within(running).getByText('com_ui_background_tasks_running')).toBeInTheDocument(); + expect(within(pending).getByText('com_ui_background_tasks_result_pending')).toBeInTheDocument(); + expect( + within(pending).getByText('com_ui_background_tasks_result_available'), + ).toBeInTheDocument(); + expect(within(pending).queryByText('com_ui_background_tasks_no_output')).toBeNull(); + }); + + it('shows an error with a failed task outcome and its actual error text', () => { + renderCall( + JSON.stringify({ ...completed, status: 'error', result: undefined, error: 'Disk full' }), + ); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'failed'); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.getByText('com_ui_failed')).toBeInTheDocument(); + expect(screen.getByText('Disk full')).toBeInTheDocument(); + }); + + it('shows accepted subagent controls as queued while preserving the control id in raw details', () => { + renderCall( + JSON.stringify({ + background_task_id: 'bg-1', + tool: 'subagent', + subagent_type: 'researcher', + status: 'accepted', + control_id: 'control-42', + }), + ); + fireEvent.click(screen.getByTestId('task-header')); + const card = screen.getByTestId('background-task-card'); + expect(within(card).getByText('com_ui_background_tasks_control_queued')).toBeInTheDocument(); + expect(within(card).getByText('researcher')).toBeInTheDocument(); + expect(within(card).queryByText('com_ui_background_tasks_completed')).toBeNull(); + expect(screen.queryByText(/control-42/)).not.toBeInTheDocument(); + }); + + it('keeps an already-claimed subagent result out of the available-on-request state', () => { + renderCall( + JSON.stringify({ + background_task_id: 'bg-1', + tool: 'subagent', + subagent_type: 'researcher', + status: 'claimed', + result_available: true, + result_claimed: true, + }), + ); + fireEvent.click(screen.getByTestId('task-header')); + const card = screen.getByTestId('background-task-card'); + expect(within(card).getByText('com_ui_background_tasks_result_claimed')).toBeInTheDocument(); + expect(within(card).queryByText('com_ui_background_tasks_result_available')).toBeNull(); + }); + + it('marks an interrupted subagent poll as failed even when the poll run step succeeded', () => { + renderCall( + JSON.stringify({ + background_task_id: 'bg-1', + tool: 'subagent', + status: 'interrupted', + error: 'Server restarted before completion.', + }), + { runStepStatus: 'completed' }, + ); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'failed'); + fireEvent.click(screen.getByTestId('task-header')); + const card = screen.getByTestId('background-task-card'); + expect(within(card).getByText('com_ui_subagent_thread_status_interrupted')).toBeInTheDocument(); + expect(within(card).getByText('Server restarted before completion.')).toBeInTheDocument(); + }); + + it.each([ + ['invalid', 'com_ui_background_tasks_notice_invalid'], + ['rejected', 'com_ui_background_tasks_notice_rejected'], + ['unavailable', 'com_ui_background_tasks_notice_unavailable'], + ['not_found', 'com_ui_background_tasks_notice_not_found'], + ['outcome_unknown', 'com_ui_background_tasks_notice_outcome_unknown'], + ['result_unavailable', 'com_ui_background_tasks_notice_result_unavailable'], + ])( + 'announces a %s warning notice as a failed check even if the tool step succeeded', + (status, label) => { + const hostMessage = 'Host advice in English, for the agent only.'; + const { container } = renderCall(JSON.stringify({ status, message: hostMessage }), { + runStepStatus: 'completed', + }); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'failed'); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(label); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.getAllByText(label).length).toBeGreaterThan(1); + expect(screen.queryByText(hostMessage)).not.toBeInTheDocument(); + }, + ); + + it('localizes guidance without promising that a running task or pending result has finished', () => { + const note = + 'Still running outside this turn; its result will arrive as a new turn when it finishes.'; + const message = + 'Automatic completion delivery is enabled for this subagent task. Continue independent work if available; otherwise end this turn and the host will resume you when the task finishes. Do not repeatedly poll an unchanged running task. Use check_background_task only for explicit status or control, or as a fallback if automatic delivery is unavailable.'; + renderCall( + JSON.stringify({ + tasks: [{ background_task_id: 'bg-1', tool: 'subagent', status: 'running', note, message }], + message, + }), + ); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.getByText('com_ui_background_tasks_running_elsewhere')).toBeInTheDocument(); + expect(screen.getAllByText('com_ui_background_tasks_subagent_wakeup_guidance')).toHaveLength(2); + expect(screen.queryByText(note)).not.toBeInTheDocument(); + expect(screen.queryByText(message)).not.toBeInTheDocument(); + }); + + it('shows unknown host prose only inside raw task details, while preserving tool result text', () => { + const note = 'Advice from a later server version.'; + const raw = JSON.stringify({ + background_task_id: 'bg-1', + tool: 'bash_tool', + status: 'completed', + result: 'Tool-created output remains visible in its own language.', + note, + }); + renderCall(raw); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.getByText('com_ui_background_tasks_more_in_raw_details')).toBeInTheDocument(); + expect( + screen.getByText('Tool-created output remains visible in its own language.'), + ).toBeInTheDocument(); + expect(screen.queryByText(note)).not.toBeInTheDocument(); + const details = screen.getByText('com_ui_background_tasks_raw_details').closest('details'); + if (details == null) { + throw new Error('Task details disclosure is missing'); + } + details.open = true; + fireEvent(details, new Event('toggle', { bubbles: true })); + expect(screen.getAllByTestId('task-output')).toHaveLength(2); + expect(screen.getAllByTestId('task-output')[1]).toHaveTextContent(note); + }); + + it('shows control notices as localized text and preserves unfamiliar responses as raw output', () => { + const { rerender } = renderCall( + JSON.stringify({ + status: 'delivery_scheduled', + background_task_id: 'bg-1', + message: 'This result will arrive in a new turn.', + }), + ); + fireEvent.click(screen.getByTestId('task-header')); + expect( + screen.getAllByText('com_ui_background_tasks_notice_delivery_scheduled').length, + ).toBeGreaterThan(1); + expect(screen.queryByText('This result will arrive in a new turn.')).not.toBeInTheDocument(); + expect(screen.queryByTestId('tool-call-info')).not.toHaveAttribute('data-output'); + + const raw = '{"status":"unexpected","result":"preserve this"}'; + rerender( + + + , + ); + expect(screen.getByTestId('tool-call-info')).toHaveAttribute('data-output', raw); + }); + + it('reveals the exact control receipt only when raw details are opened', () => { + const output = JSON.stringify({ + status: 'accepted', + background_task_id: 'bg-1', + control_id: 'control-42', + message: 'The control request was queued.', + }); + renderCall(output); + fireEvent.click(screen.getByTestId('task-header')); + expect(screen.queryByText(/control-42/)).not.toBeInTheDocument(); + + const details = screen.getByText('com_ui_background_tasks_raw_details').closest('details'); + expect(details).not.toBeNull(); + if (details == null) { + throw new Error('Task details disclosure is missing'); + } + details.open = true; + fireEvent(details, new Event('toggle', { bubbles: true })); + expect(screen.getByTestId('task-output')).toHaveTextContent('control-42'); + }); + + it('announces stable progress across intent deltas, then the settled intent once', () => { + const firstArgs = '{"intent":"Checking the backgr'; + const finalArgs = '{"intent":"Checking the background task","background_task_id":"bg-1"}'; + const { container, rerender } = renderCall('', { + args: firstArgs, + isSubmitting: true, + initialProgress: 0.1, + }); + const announcement = container.querySelector('[aria-live="polite"]'); + expect(announcement).toHaveClass('sr-only'); + expect(announcement).toHaveAttribute('aria-atomic', 'true'); + expect(announcement).toHaveTextContent('com_ui_background_tasks_checking'); + rerender( + + + , + ); + expect(announcement).toHaveTextContent('com_ui_background_tasks_checking'); + rerender( + + + , + ); + expect(announcement).toHaveTextContent('Checking the background task'); + }); + + it.each([ + ['delivery_scheduled', 'com_ui_background_tasks_notice_delivery_scheduled'], + ['result_persisting', 'com_ui_background_tasks_notice_result_persisting'], + ])('announces %s as a pending state rather than a task failure', (status, label) => { + const { container } = renderCall( + JSON.stringify({ status, message: 'Untranslated backend status message.' }), + { runStepStatus: 'completed' }, + ); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'completed'); + expect(screen.getByTestId('task-header')).toHaveTextContent(label); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(label); + }); + + it.each([ + ['failed', 'com_ui_failed_subject'], + ['cancelled', 'com_ui_cancelled'], + ])('announces an explicit %s tool-run status ahead of benign output', (runStepStatus, label) => { + const { container } = renderCall( + JSON.stringify({ status: 'delivery_scheduled', message: 'The result will arrive later.' }), + { runStepStatus: runStepStatus as 'failed' | 'cancelled' }, + ); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', runStepStatus); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(label); + expect(container.querySelector('[aria-live="polite"]')).not.toHaveTextContent( + 'com_ui_background_tasks_notice_delivery_scheduled', + ); + }); + + it('announces cancellation rather than a successful check when a result is discarded', () => { + const { container } = renderCall( + JSON.stringify({ status: 'cancelled', message: 'Discarded the result.' }), + { + runStepStatus: 'completed', + }, + ); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'cancelled'); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent( + 'com_ui_background_tasks_result_discarded', + ); + }); + + it('keeps the row in progress while a poll streams and updates it after settlement', () => { + const { rerender } = renderCall('', { isSubmitting: true, initialProgress: 0.1 }); + expect(screen.getByTestId('task-header')).toHaveAttribute('data-phase', 'running'); + fireEvent.click(screen.getByTestId('task-header')); + rerender( + + + , + ); + expect(screen.getByTestId('task-header')).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByTestId('task-output')).toHaveTextContent('checked at=2026-09-27'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/background.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/background.test.ts new file mode 100644 index 00000000000..6726754532d --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/background.test.ts @@ -0,0 +1,293 @@ +import { + backgroundTaskOutcome, + formatBackgroundCodeOutput, + parseBackgroundTaskOutput, +} from '../background'; + +describe('formatBackgroundCodeOutput', () => { + it('indents a JSON log line without changing surrounding stdout and exit code', () => { + const raw = + 'stdout:\nchecked at=2026-09-27T00:11:09Z\n{"checks":[{"status":"IN_PROGRESS"}]}\n[exit code: 0]'; + expect(formatBackgroundCodeOutput(raw)).toBe( + 'stdout:\nchecked at=2026-09-27T00:11:09Z\n{\n "checks": [\n {\n "status": "IN_PROGRESS"\n }\n ]\n}\n[exit code: 0]', + ); + }); + + it('leaves non-JSON lines, single JSON results, and very large outputs untouched', () => { + expect(formatBackgroundCodeOutput('stdout:\n[exit code: 0]')).toBe('stdout:\n[exit code: 0]'); + expect(formatBackgroundCodeOutput('{"ok":true}')).toBe('{"ok":true}'); + const large = `stdout:\n${'a'.repeat(64_001)}`; + expect(formatBackgroundCodeOutput(large)).toBe(large); + }); +}); + +describe('backgroundTaskOutcome', () => { + it.each([ + 'invalid', + 'rejected', + 'unavailable', + 'not_found', + 'outcome_unknown', + 'result_unavailable', + 'error', + 'future_control_rejected', + ])('flags the %s notice as a failed task check, despite a successful tool run step', (status) => { + const display = parseBackgroundTaskOutput(JSON.stringify({ status, message: 'Host advice' })); + expect(backgroundTaskOutcome(display)).toBe('failed'); + }); + + it.each(['delivery_scheduled', 'result_persisting'])( + 'does not mistake the %s retry state for a failed task', + (status) => { + const display = parseBackgroundTaskOutput(JSON.stringify({ status, message: 'Host advice' })); + expect(backgroundTaskOutcome(display)).toBeUndefined(); + }, + ); + + it.each(['error', 'interrupted', 'failed', 'not_running', 'control_not_found'])( + 'flags a %s task or control receipt as failed', + (status) => { + const display = parseBackgroundTaskOutput( + JSON.stringify({ background_task_id: 't1', tool: 'subagent', status }), + ); + expect(backgroundTaskOutcome(display)).toBe('failed'); + }, + ); + + it('distinguishes a discarded result from a still-running cancellation request', () => { + expect( + backgroundTaskOutcome( + parseBackgroundTaskOutput( + JSON.stringify({ status: 'cancelled', message: 'Result discarded.' }), + ), + ), + ).toBe('cancelled'); + expect( + backgroundTaskOutcome( + parseBackgroundTaskOutput( + JSON.stringify({ + background_task_id: 't1', + tool: 'bash_tool', + status: 'cancellation_requested', + }), + ), + ), + ).toBeUndefined(); + }); + + it('warns about an incomplete list, not an ordinary list of already failed tasks', () => { + const incomplete = parseBackgroundTaskOutput( + JSON.stringify({ tasks: [], partial: true, warning: 'Some results are missing.' }), + ); + const complete = parseBackgroundTaskOutput( + JSON.stringify({ + tasks: [{ background_task_id: 't1', tool: 'bash_tool', status: 'error' }], + partial: false, + }), + ); + expect(backgroundTaskOutcome(incomplete)).toBe('failed'); + expect(backgroundTaskOutcome(complete)).toBeUndefined(); + }); +}); + +describe('parseBackgroundTaskOutput', () => { + it('parses an ordinary task result with the receipt and its exact output', () => { + const result = + 'stdout:\nchecked at=2026-09-27T00:11:09Z\n{"checks":[{"status":"IN_PROGRESS"}]}\n[exit code: 0]'; + expect( + parseBackgroundTaskOutput( + JSON.stringify({ + background_task_id: 'task-1', + tool: 'bash_tool', + status: 'completed', + result, + delivery: 'delivered', + started_at: '2026-09-27T00:11:00Z', + }), + ), + ).toEqual({ + kind: 'task', + task: { + taskId: 'task-1', + toolName: 'bash_tool', + status: 'completed', + result, + delivery: 'delivered', + }, + }); + }); + + it('parses running, failed, cancelled, and pending tasks in a list without inventing results', () => { + expect( + parseBackgroundTaskOutput( + JSON.stringify({ + tasks: [ + { background_task_id: 't1', tool: 'web_search', status: 'running' }, + { + background_task_id: 't2', + tool: 'execute_code', + status: 'completed', + delivery: 'pending', + result_available: true, + }, + { + background_task_id: 't3', + tool: 'read_file', + status: 'error', + error: 'Could not read file', + delivery: 'failed', + }, + { + background_task_id: 't4', + tool: 'subagent', + status: 'cancelled', + subagent_type: 'researcher', + }, + ], + outstanding: 2, + partial: true, + warning: 'Some results were not loaded.', + }), + ), + ).toEqual({ + kind: 'list', + tasks: [ + { taskId: 't1', toolName: 'web_search', status: 'running' }, + { + taskId: 't2', + toolName: 'execute_code', + status: 'completed', + delivery: 'pending', + resultAvailable: true, + }, + { + taskId: 't3', + toolName: 'read_file', + status: 'error', + error: 'Could not read file', + delivery: 'failed', + }, + { + taskId: 't4', + toolName: 'subagent', + status: 'cancelled', + subagentType: 'researcher', + }, + ], + partial: true, + warning: 'Some results were not loaded.', + }); + }); + + it('shows a cancellation request as still stopping rather than cancelled', () => { + expect( + parseBackgroundTaskOutput( + JSON.stringify({ + background_task_id: 'task-1', + tool: 'bash_tool', + status: 'cancellation_requested', + message: 'Cancellation requested; execution continues until settlement.', + }), + ), + ).toMatchObject({ kind: 'task', task: { status: 'stopping' } }); + }); + + it('shows a cancellation_requested flag in a list as stopping, not cancelled', () => { + expect( + parseBackgroundTaskOutput( + JSON.stringify({ + tasks: [ + { + background_task_id: 'task-1', + tool: 'bash_tool', + status: 'running', + cancellation_requested: true, + }, + ], + outstanding: 1, + }), + ), + ).toMatchObject({ kind: 'list', tasks: [{ status: 'stopping' }] }); + }); + + it('keeps accepted subagent controls queued rather than labeling them completed', () => { + expect( + parseBackgroundTaskOutput( + JSON.stringify({ + background_task_id: 'subagent-1', + subagent_thread_id: 'thread-1', + tool: 'subagent', + subagent_type: 'researcher', + status: 'accepted', + control_id: 'control-42', + }), + ), + ).toEqual({ + kind: 'task', + task: { + taskId: 'subagent-1', + toolName: 'subagent', + subagentType: 'researcher', + status: 'accepted', + }, + }); + }); + + it.each(['claimed', 'not_running', 'control_not_found'])( + 'renders a subagent %s receipt as a task instead of raw JSON', + (status) => { + expect( + parseBackgroundTaskOutput( + JSON.stringify({ + background_task_id: 'task-1', + tool: 'subagent', + subagent_type: 'researcher', + status, + result_available: true, + result_claimed: true, + }), + ), + ).toEqual({ + kind: 'task', + task: { + taskId: 'task-1', + toolName: 'subagent', + subagentType: 'researcher', + status, + resultAvailable: true, + resultClaimed: true, + }, + }); + }, + ); + + it('recognizes delivery and invalid-id notices without pretending they are tasks', () => { + const message = 'The result is already assigned to an automatic continuation.'; + expect( + parseBackgroundTaskOutput( + JSON.stringify({ status: 'delivery_scheduled', background_task_id: 't1', message }), + ), + ).toEqual({ kind: 'notice', status: 'delivery_scheduled', message }); + }); + + it.each([ + '', + '{"tasks":', + JSON.stringify({ tasks: [{ background_task_id: 't1', tool: 'bash_tool', status: 'unknown' }] }), + JSON.stringify({ + tasks: [ + { background_task_id: 't1', tool: 'bash_tool', status: 'running', delivery: 'unknown' }, + ], + }), + JSON.stringify({ + background_task_id: 't1', + tool: 'bash_tool', + status: 'completed', + result: {}, + }), + JSON.stringify({ status: 'completed', result: 'unrelated tool output' }), + JSON.stringify({ tasks: 'corrupt', status: 'invalid', message: 'Retry the request.' }), + ])('keeps malformed or unknown tool output on the raw fallback path', (output) => { + expect(parseBackgroundTaskOutput(output)).toBeNull(); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/guidance.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/guidance.test.ts new file mode 100644 index 00000000000..348f86cb790 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/guidance.test.ts @@ -0,0 +1,177 @@ +import { + backgroundListGuidanceKeys, + backgroundTaskMessageKey, + backgroundTaskNoteKey, + backgroundTaskNoticeKey, +} from '../guidance'; +import { parseBackgroundTaskOutput } from '../background'; + +describe('background task host guidance', () => { + it.each([ + [ + 'Generated files were saved and attached to the tool call that dispatched this task.', + 'com_ui_background_tasks_files_attached', + ], + [ + 'Output and any generated files are being attached to the tool call that dispatched this task.', + 'com_ui_background_tasks_files_attaching', + ], + [ + 'The tool produced an artifact that is not included inline.', + 'com_ui_background_tasks_artifact_not_inline', + ], + [ + 'Still running outside this turn; its result will arrive as a new turn when it finishes.', + 'com_ui_background_tasks_running_elsewhere', + ], + [ + 'Finished, but its result has not been delivered; it will arrive as a new turn unless you poll or cancel it.', + 'com_ui_background_tasks_pending_delivery_note', + ], + [ + 'Automatic delivery failed; this result will not arrive as a new turn. Poll it to collect the result.', + 'com_ui_background_tasks_failed_delivery_note', + ], + ])('localizes known task note variants: %s', (note, expected) => { + expect(backgroundTaskNoteKey(note)).toBe(expected); + }); + + it('keeps unknown host notes and messages behind raw details', () => { + expect(backgroundTaskNoteKey('New server-side note with context.')).toBe( + 'com_ui_background_tasks_more_in_raw_details', + ); + expect(backgroundTaskMessageKey('New server-side control advice.')).toBe( + 'com_ui_background_tasks_more_in_raw_details', + ); + expect(backgroundTaskNoteKey('constructor')).toBe( + 'com_ui_background_tasks_more_in_raw_details', + ); + expect(backgroundTaskMessageKey('__proto__')).toBe( + 'com_ui_background_tasks_more_in_raw_details', + ); + expect(backgroundTaskNoticeKey('constructor', 'Unexpected status.')).toBe( + 'com_ui_background_tasks_more_in_raw_details', + ); + }); + + it('localizes known task messages without losing the cancellation and automatic-delivery rules', () => { + expect( + backgroundTaskMessageKey( + 'Cancellation was requested. The task remains active until its executor settles; poll again for a terminal result.', + ), + ).toBe('com_ui_background_tasks_cancellation_requested'); + expect( + backgroundTaskMessageKey( + 'Automatic completion delivery is enabled for this subagent task. Continue independent work if available; otherwise end this turn and the host will resume you when the task finishes. Do not repeatedly poll an unchanged running task. Use check_background_task only for explicit status or control, or as a fallback if automatic delivery is unavailable.', + ), + ).toBe('com_ui_background_tasks_subagent_wakeup_guidance'); + }); + + it.each([ + ['invalid', 'Wrong arguments.', 'com_ui_background_tasks_notice_invalid'], + ['rejected', 'Capacity reached.', 'com_ui_background_tasks_notice_rejected'], + ['not_found', 'No task here.', 'com_ui_background_tasks_notice_not_found'], + [ + 'outcome_unknown', + 'Unable to confirm result.', + 'com_ui_background_tasks_notice_outcome_unknown', + ], + [ + 'result_unavailable', + 'Receipt temporarily unavailable.', + 'com_ui_background_tasks_notice_result_unavailable', + ], + [ + 'result_persisting', + 'Receipt is settling.', + 'com_ui_background_tasks_notice_result_persisting', + ], + [ + 'delivery_scheduled', + 'Assigned to another poll.', + 'com_ui_background_tasks_notice_delivery_scheduled', + ], + ['unavailable', 'Process disconnected.', 'com_ui_background_tasks_notice_unavailable'], + ['cancelled', 'Result discarded.', 'com_ui_background_tasks_result_discarded'], + ])('gives %s a localized, conservative notice', (status, message, expected) => { + expect(backgroundTaskNoticeKey(status, message)).toBe(expected); + }); + + it('distinguishes disabled cancellation, unsupported controls, and a failed discard', () => { + expect( + backgroundTaskNoticeKey( + 'invalid', + 'Cancellation is not enabled for ordinary background tools.', + ), + ).toBe('com_ui_background_tasks_cancel_disabled'); + expect( + backgroundTaskNoticeKey( + 'invalid', + 'This control action is supported only for subagent tasks.', + ), + ).toBe('com_ui_background_tasks_notice_subagent_only'); + expect( + backgroundTaskNoticeKey( + 'unavailable', + 'The pending result could not be discarded right now. It may still arrive as a new turn; retry the cancel shortly.', + ), + ).toBe('com_ui_background_tasks_notice_discard_unavailable'); + expect(backgroundTaskNoticeKey('new_status', 'Unexpected server advice.')).toBe( + 'com_ui_background_tasks_more_in_raw_details', + ); + }); + + it('summarizes pending and failed delivery from task status, not backend English', () => { + const display = parseBackgroundTaskOutput( + JSON.stringify({ + tasks: [ + { + background_task_id: 'ordinary-pending', + tool: 'bash_tool', + status: 'completed', + delivery: 'pending', + }, + { + background_task_id: 'ordinary-failed', + tool: 'read_file', + status: 'completed', + delivery: 'failed', + }, + { + background_task_id: 'subagent-pending', + tool: 'subagent', + status: 'error', + delivery: 'pending', + }, + { background_task_id: 'subagent-running', tool: 'subagent', status: 'running' }, + ], + message: + 'Some other text. Automatic completion delivery is enabled for this subagent task.', + }), + ); + if (display?.kind !== 'list') { + throw new Error('Expected a task list'); + } + expect(backgroundListGuidanceKeys(display)).toEqual([ + 'com_ui_background_tasks_pending_delivery_guidance', + 'com_ui_background_tasks_failed_delivery_guidance', + 'com_ui_background_tasks_subagent_pending_guidance', + 'com_ui_background_tasks_subagent_wakeup_guidance', + ]); + }); + + it('never promises automatic delivery for a running subagent without host guidance', () => { + const display = parseBackgroundTaskOutput( + JSON.stringify({ + tasks: [{ background_task_id: 'subagent', tool: 'subagent', status: 'running' }], + message: 'Poll for results manually.', + }), + ); + if (display?.kind !== 'list') { + throw new Error('Expected a task list'); + } + expect(backgroundListGuidanceKeys(display)).toEqual([ + 'com_ui_background_tasks_more_in_raw_details', + ]); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts index 682a2d62a3e..a20ec801c31 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/wakeup.test.ts @@ -73,6 +73,33 @@ describe('parseWakeupText', () => { }); }); + it('parses a cancelled background tool wake-up without exposing its host envelope', () => { + const text = [ + 'A background tool task has finished. Continue using its durable result below.', + JSON.stringify([ + { + background_task_id: 'bg-2', + tool_call_id: 'call-2', + tool: 'bash_tool', + status: 'cancelled', + result: 'Cancelled by the user.', + }, + ]), + ].join('\n'); + expect(parseWakeupText(text)).toEqual({ + kind: 'background_tool', + tasks: [ + { + taskId: 'bg-2', + toolCallId: 'call-2', + toolName: 'bash_tool', + status: 'cancelled', + result: 'Cancelled by the user.', + }, + ], + }); + }); + it('parses a plural background tool wake-up', () => { const text = [ '2 background tool tasks have finished. Continue using their durable results below.', diff --git a/client/src/components/Chat/Messages/Content/Parts/background.ts b/client/src/components/Chat/Messages/Content/Parts/background.ts new file mode 100644 index 00000000000..db5589de06f --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/background.ts @@ -0,0 +1,217 @@ +import type { BackgroundTaskDelivery } from 'librechat-data-provider'; +import type { WakeupTaskStatus } from './wakeup'; + +export function formatBackgroundCodeOutput(output: string): string { + if (output.length > 64_000 || !output.includes('\n')) { + return output; + } + const lines = output.split('\n'); + if (lines.length > 500) { + return output; + } + return lines + .map((line) => { + const trimmed = line.trim(); + if ( + trimmed.length < 2 || + trimmed.length > 8_000 || + !( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) + ) { + return line; + } + try { + const value: unknown = JSON.parse(trimmed); + if (value == null || typeof value !== 'object') { + return line; + } + const indent = line.slice(0, line.length - line.trimStart().length); + return `${indent}${JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`)}`; + } catch { + return line; + } + }) + .join('\n'); +} + +export type BackgroundTaskStatus = + | WakeupTaskStatus + | 'running' + | 'stopping' + | 'accepted' + | 'claimed' + | 'not_running' + | 'control_not_found' + | 'dispatched' + | 'failed' + | 'interrupted'; + +export type BackgroundTaskView = { + taskId: string; + toolName: string; + status: BackgroundTaskStatus; + result?: string; + error?: string; + note?: string; + message?: string; + subagentType?: string; + resultAvailable?: boolean; + resultClaimed?: boolean; + delivery?: BackgroundTaskDelivery; +}; + +export type BackgroundTaskDisplay = + | { kind: 'task'; task: BackgroundTaskView } + | { + kind: 'list'; + tasks: BackgroundTaskView[]; + partial: boolean; + warning?: string; + message?: string; + } + | { kind: 'notice'; message: string; status: string }; + +const PENDING_NOTICES = new Set(['delivery_scheduled', 'result_persisting']); + +/** The poll step succeeding is not proof that its underlying task or control succeeded. */ +export function backgroundTaskOutcome( + display: BackgroundTaskDisplay | null, +): 'failed' | 'cancelled' | undefined { + if (display == null) { + return; + } + if (display.kind === 'notice') { + if (display.status === 'cancelled') { + return 'cancelled'; + } + return PENDING_NOTICES.has(display.status) ? undefined : 'failed'; + } + if (display.kind === 'list') { + return display.partial || display.warning ? 'failed' : undefined; + } + const status = display.task.status; + if (status === 'cancelled') { + return 'cancelled'; + } + return status === 'error' || + status === 'failed' || + status === 'interrupted' || + status === 'not_running' || + status === 'control_not_found' + ? 'failed' + : undefined; +} + +const isRecord = (value: unknown): value is Record => + value != null && typeof value === 'object' && !Array.isArray(value); + +const optionalString = (value: unknown): value is string | undefined => + value === undefined || typeof value === 'string'; + +const taskStatus = (value: unknown): BackgroundTaskStatus | null => { + if (value === 'cancellation_requested') { + return 'stopping'; + } + if ( + value === 'running' || + value === 'accepted' || + value === 'claimed' || + value === 'not_running' || + value === 'control_not_found' || + value === 'completed' || + value === 'error' || + value === 'cancelled' || + value === 'dispatched' || + value === 'failed' || + value === 'interrupted' + ) { + return value; + } + return null; +}; + +const deliveryStatus = (value: unknown): value is BackgroundTaskDelivery | undefined => + value === undefined || value === 'pending' || value === 'failed' || value === 'delivered'; + +function parseTask(value: unknown): BackgroundTaskView | null { + if (!isRecord(value)) { + return null; + } + const status = taskStatus(value.status); + if ( + typeof value.background_task_id !== 'string' || + value.background_task_id === '' || + typeof value.tool !== 'string' || + value.tool === '' || + status == null || + !optionalString(value.result) || + !optionalString(value.error) || + !optionalString(value.note) || + !optionalString(value.message) || + !optionalString(value.subagent_type) || + (value.result_available != null && typeof value.result_available !== 'boolean') || + (value.result_claimed != null && typeof value.result_claimed !== 'boolean') || + !deliveryStatus(value.delivery) + ) { + return null; + } + return { + taskId: value.background_task_id, + toolName: value.tool, + status: status === 'running' && value.cancellation_requested === true ? 'stopping' : status, + ...(typeof value.result === 'string' ? { result: value.result } : {}), + ...(typeof value.error === 'string' ? { error: value.error } : {}), + ...(typeof value.note === 'string' ? { note: value.note } : {}), + ...(typeof value.message === 'string' ? { message: value.message } : {}), + ...(typeof value.subagent_type === 'string' ? { subagentType: value.subagent_type } : {}), + ...(value.result_available === true ? { resultAvailable: true } : {}), + ...(value.result_claimed === true ? { resultClaimed: true } : {}), + ...(value.delivery != null ? { delivery: value.delivery } : {}), + }; +} + +/** Only host-shaped results become cards. Unknown or partial output stays visible as raw tool output. */ +export function parseBackgroundTaskOutput(output?: string | null): BackgroundTaskDisplay | null { + if (!output || output.length > 2_000_000) { + return null; + } + const text = output.trim(); + if (!text.startsWith('{') || !text.endsWith('}')) { + return null; + } + let payload: unknown; + try { + payload = JSON.parse(text) as unknown; + } catch { + return null; + } + if (!isRecord(payload)) { + return null; + } + if ('tasks' in payload && !Array.isArray(payload.tasks)) { + return null; + } + if (Array.isArray(payload.tasks)) { + const tasks = payload.tasks.map(parseTask); + if (tasks.some((task) => task == null)) { + return null; + } + return { + kind: 'list', + tasks: tasks as BackgroundTaskView[], + partial: payload.partial === true, + ...(typeof payload.warning === 'string' ? { warning: payload.warning } : {}), + ...(typeof payload.message === 'string' ? { message: payload.message } : {}), + }; + } + const task = parseTask(payload); + if (task != null) { + return { kind: 'task', task }; + } + if (typeof payload.status === 'string' && typeof payload.message === 'string') { + return { kind: 'notice', status: payload.status, message: payload.message }; + } + return null; +} diff --git a/client/src/components/Chat/Messages/Content/Parts/guidance.ts b/client/src/components/Chat/Messages/Content/Parts/guidance.ts new file mode 100644 index 00000000000..09e3821f581 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/guidance.ts @@ -0,0 +1,106 @@ +import type { BackgroundTaskDisplay } from './background'; +import type { TranslationKeys } from '~/hooks'; + +const RAW_DETAILS_KEY: TranslationKeys = 'com_ui_background_tasks_more_in_raw_details'; + +/** Backend messages are model-facing English; show localized labels, not their raw prose. */ +const NOTE_KEYS: Record = { + 'Generated files were saved and attached to the tool call that dispatched this task.': + 'com_ui_background_tasks_files_attached', + 'Output and any generated files are being attached to the tool call that dispatched this task.': + 'com_ui_background_tasks_files_attaching', + 'The tool produced an artifact that is not included inline.': + 'com_ui_background_tasks_artifact_not_inline', + 'Still running outside this turn; its result will arrive as a new turn when it finishes.': + 'com_ui_background_tasks_running_elsewhere', + 'Finished, but its result has not been delivered; it will arrive as a new turn unless you poll or cancel it.': + 'com_ui_background_tasks_pending_delivery_note', + 'Automatic delivery failed; this result will not arrive as a new turn. Poll it to collect the result.': + 'com_ui_background_tasks_failed_delivery_note', +}; + +const MESSAGE_KEYS: Record = { + 'Cancellation was requested. The task remains active until its executor settles; poll again for a terminal result.': + 'com_ui_background_tasks_cancellation_requested', + 'Automatic completion delivery is enabled for this subagent task. Continue independent work if available; otherwise end this turn and the host will resume you when the task finishes. Do not repeatedly poll an unchanged running task. Use check_background_task only for explicit status or control, or as a fallback if automatic delivery is unavailable.': + 'com_ui_background_tasks_subagent_wakeup_guidance', +}; + +const NOTICE_KEYS: Record = { + invalid: 'com_ui_background_tasks_notice_invalid', + rejected: 'com_ui_background_tasks_notice_rejected', + unavailable: 'com_ui_background_tasks_notice_unavailable', + not_found: 'com_ui_background_tasks_notice_not_found', + outcome_unknown: 'com_ui_background_tasks_notice_outcome_unknown', + result_unavailable: 'com_ui_background_tasks_notice_result_unavailable', + result_persisting: 'com_ui_background_tasks_notice_result_persisting', + delivery_scheduled: 'com_ui_background_tasks_notice_delivery_scheduled', + cancelled: 'com_ui_background_tasks_result_discarded', + error: 'com_ui_background_tasks_notice_error', +}; + +export const backgroundTaskNoteKey = (note: string): TranslationKeys => + Object.hasOwn(NOTE_KEYS, note) ? NOTE_KEYS[note] : RAW_DETAILS_KEY; + +export const backgroundTaskMessageKey = (message: string): TranslationKeys => + Object.hasOwn(MESSAGE_KEYS, message) ? MESSAGE_KEYS[message] : RAW_DETAILS_KEY; + +export function backgroundTaskNoticeKey(status: string, message: string): TranslationKeys { + if ( + status === 'invalid' && + message === 'Cancellation is not enabled for ordinary background tools.' + ) { + return 'com_ui_background_tasks_cancel_disabled'; + } + if ( + status === 'invalid' && + message === 'This control action is supported only for subagent tasks.' + ) { + return 'com_ui_background_tasks_notice_subagent_only'; + } + if ( + status === 'unavailable' && + message.startsWith('The pending result could not be discarded right now.') + ) { + return 'com_ui_background_tasks_notice_discard_unavailable'; + } + return Object.hasOwn(NOTICE_KEYS, status) ? NOTICE_KEYS[status] : RAW_DETAILS_KEY; +} + +export function backgroundListGuidanceKeys( + display: Extract, +): TranslationKeys[] { + if (!display.message) { + return []; + } + let toolPending = false; + let toolFailed = false; + let subagentPending = false; + let subagentRunning = false; + for (const task of display.tasks) { + if (task.toolName === 'subagent') { + subagentPending ||= task.delivery === 'pending' && task.status !== 'running'; + subagentRunning ||= task.status === 'running'; + continue; + } + toolPending ||= task.delivery === 'pending' && task.status !== 'running'; + toolFailed ||= task.delivery === 'failed'; + } + const keys: TranslationKeys[] = []; + if (toolPending) { + keys.push('com_ui_background_tasks_pending_delivery_guidance'); + } + if (toolFailed) { + keys.push('com_ui_background_tasks_failed_delivery_guidance'); + } + if (subagentPending) { + keys.push('com_ui_background_tasks_subagent_pending_guidance'); + } + if ( + subagentRunning && + display.message.includes('Automatic completion delivery is enabled for this subagent task.') + ) { + keys.push('com_ui_background_tasks_subagent_wakeup_guidance'); + } + return keys.length > 0 ? keys : [RAW_DETAILS_KEY]; +} diff --git a/client/src/components/Chat/Messages/Content/Parts/wakeup.ts b/client/src/components/Chat/Messages/Content/Parts/wakeup.ts index ce46d45990c..1b90e90ee65 100644 --- a/client/src/components/Chat/Messages/Content/Parts/wakeup.ts +++ b/client/src/components/Chat/Messages/Content/Parts/wakeup.ts @@ -72,7 +72,6 @@ const backgroundWakeupTask = (payload: unknown): WakeupTask | null => { const status = wakeupStatus(payload.status); if ( status == null || - status === 'cancelled' || typeof payload.background_task_id !== 'string' || typeof payload.tool_call_id !== 'string' || typeof payload.tool !== 'string' || diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index 1a47e3a7015..c0080bf4cd5 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,8 +1,8 @@ import { useState, useRef, useMemo, useEffect, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; import { Button } from '@librechat/client'; -import { ChevronDown, MessageCircleQuestion, Users } from 'lucide-react'; import { Tools, Constants, ContentTypes } from 'librechat-data-provider'; +import { ChevronDown, ListChecks, MessageCircleQuestion, Users } from 'lucide-react'; import type { TAttachment, TMessageContentParts } from 'librechat-data-provider'; import type { PartWithIndex } from './ParallelContent'; import type { ToolMeta } from './outcome'; @@ -144,6 +144,7 @@ export default function ToolCallGroup({ let fileSearchCount = 0; let subagentCount = 0; let askQuestionCount = 0; + let taskCheckCount = 0; let failedCount = 0; let cancelledCount = 0; @@ -156,6 +157,8 @@ export default function ToolCallGroup({ subagentCount++; } else if (tool.name === ASK_USER_QUESTION) { askQuestionCount++; + } else if (tool.name === Constants.CHECK_BACKGROUND_TASK) { + taskCheckCount++; } if (tool.failed) { failedCount++; @@ -183,6 +186,7 @@ export default function ToolCallGroup({ fileSearchCount, subagentCount, askQuestionCount, + taskCheckCount, failedCount, cancelledCount, toolNameSummary, @@ -215,6 +219,8 @@ export default function ToolCallGroup({ * tools: ask_user_question") with a question glyph. */ const allAskQuestions = activitySummary.askQuestionCount > 0 && activitySummary.askQuestionCount === count; + const allTaskChecks = + activitySummary.taskCheckCount > 0 && activitySummary.taskCheckCount === count; /** * An answered question is the group's completion proof. The `tool_call` * part carries no output until the turn finalizes, so `allCompleted` stays @@ -405,6 +411,11 @@ export default function ToolCallGroup({ ? localize('com_ui_asked_n_questions', { 0: String(count) }) : localize('com_ui_asking_n_questions', { 0: String(count) }); } + if (allTaskChecks) { + return localize( + groupDone ? 'com_ui_background_tasks_checked' : 'com_ui_background_tasks_checking', + ); + } if (activitySummary.webSearchCount === count) { return localize(groupDone ? 'com_ui_web_searched' : 'com_ui_web_searching'); } @@ -430,6 +441,8 @@ export default function ToolCallGroup({ const groupDetailParts: string[] = []; if (searchesOnly && count > 1) { groupDetailParts.push(localize('com_ui_n_searches', { 0: String(count) })); + } else if (allTaskChecks && count > 1) { + groupDetailParts.push(localize('com_ui_background_tasks_n_checks', { 0: String(count) })); } else if (!allSubagents && !allAskQuestions && count > 1) { groupDetailParts.push(activitySummary.toolNameSummary); } @@ -471,7 +484,12 @@ export default function ToolCallGroup({ ? groupDetailParts.filter((part) => part && part !== failedNote).join(' · ') : groupDetail; /** Single category glyph for homogeneous groups (else StackedToolIcons). */ - const CategoryIcon = allSubagents ? Users : MessageCircleQuestion; + let CategoryIcon = ListChecks; + if (allSubagents) { + CategoryIcon = Users; + } else if (allAskQuestions) { + CategoryIcon = MessageCircleQuestion; + } const iconStatus = getOutcomeStatus({ failed: activityFailed ? 1 : activitySummary.failedCount, cancelled: activitySummary.cancelledCount, @@ -509,12 +527,9 @@ export default function ToolCallGroup({ aria-expanded={isExpanded} aria-label={groupAriaLabel} > - {iconStatus == null && (allSubagents || allAskQuestions) ? ( - /** Homogeneous category groups get a single category glyph instead - * of StackedToolIcons' generic wrenches: a Users glyph for - * subagents, a question glyph for ask_user_question — matching - * their individual card headers and reading as the category - * rather than "tools". */ + {iconStatus == null && (allSubagents || allAskQuestions || allTaskChecks) ? ( + /** Homogeneous categories keep the same glyph as their individual + * cards instead of stacking identical tool icons. */
extractText(text), [text]); const [isExpanded, setIsExpanded] = useState(false); @@ -110,9 +111,9 @@ export default function OutputRenderer({ text }: OutputRendererProps) { const handleCopy = useCallback(() => { setIsCopied(true); - copy(displayText, { format: 'text/plain' }); + copy(copyText ?? displayText, { format: 'text/plain' }); setTimeout(() => setIsCopied(false), 3000); - }, [displayText]); + }, [copyText, displayText]); if (!displayText) { return null; diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx index 61babdc2703..665b6335e09 100644 --- a/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx +++ b/client/src/components/Chat/Messages/Content/ToolOutput/ToolIcon.tsx @@ -12,6 +12,7 @@ import { Brain, Zap, Wrench, + ListChecks, } from 'lucide-react'; import LangIcon from '~/components/Messages/Content/LangIcon'; import CustomIcon from '~/components/ui/CustomIcon'; @@ -32,6 +33,7 @@ export type ToolIconType = | 'skill' | 'read_file' | 'bash_tool' + | 'background_tasks' | 'ask_user_question' | 'memory' | 'action' @@ -48,6 +50,7 @@ const ICON_MAP: Record skill: ScrollText, read_file: FileText, bash_tool: BashIcon, + background_tasks: ListChecks, ask_user_question: MessageCircleQuestion, memory: Brain, action: Zap, @@ -61,6 +64,9 @@ export function getToolIconType(name: string): ToolIconType { if (name.includes(Constants.mcp_delimiter)) { return 'mcp'; } + if (name === Constants.CHECK_BACKGROUND_TASK) { + return 'background_tasks'; + } if (name === 'execute_code' || name === Constants.PROGRAMMATIC_TOOL_CALLING) { return 'execute_code'; } diff --git a/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx b/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx index 77ff54aaeef..aff34c258fa 100644 --- a/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx +++ b/client/src/components/Chat/Messages/Content/ToolOutput/__tests__/OutputRenderer.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import copy from 'copy-to-clipboard'; +import { fireEvent, render, screen } from '@testing-library/react'; import OutputRenderer, { isError } from '../OutputRenderer'; jest.mock('copy-to-clipboard', () => jest.fn()); @@ -10,7 +11,9 @@ jest.mock('~/hooks', () => ({ jest.mock('~/components/Messages/Content/CopyButton', () => ({ __esModule: true, - default: () =>