diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
index 74317dab9de..9a62f40f7cd 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
@@ -3,8 +3,10 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { ChevronDown, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { ShimmerText } from '@/components/ui'
+import { useSmoothText } from '@/hooks/use-smooth-text'
import type { ToolCallData } from '../../../../types'
import { getAgentIcon, isToolDone } from '../../utils'
+import { renderInlineMarkdown } from './inline-markdown'
import { ToolCallItem } from './tool-call-item'
/**
@@ -148,12 +150,11 @@ export function AgentGroup({
)
}
return (
-
- {item.content.trim()}
-
+ content={item.content}
+ isStreaming={isStreaming && idx === items.length - 1}
+ />
)
})}
@@ -165,6 +166,27 @@ export function AgentGroup({
)
}
+interface NarrationTextProps {
+ content: string
+ /** This row is the group's live tail — pace its reveal like top-level text. */
+ isStreaming: boolean
+}
+
+/**
+ * A narration (thinking/text) row inside an agent group. The live tail row is
+ * paced with {@link useSmoothText} so streamed chunks reveal word-by-word
+ * instead of popping in, matching the top-level text treatment.
+ */
+function NarrationText({ content, isStreaming }: NarrationTextProps) {
+ const revealed = useSmoothText(content, isStreaming)
+
+ return (
+
+ {renderInlineMarkdown(revealed.trim())}
+
+ )
+}
+
interface BoundedViewportProps {
children: React.ReactNode
isStreaming: boolean
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx
new file mode 100644
index 00000000000..7cd670a95c4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.test.tsx
@@ -0,0 +1,84 @@
+/**
+ * @vitest-environment node
+ */
+import { isValidElement } from 'react'
+import { describe, expect, it } from 'vitest'
+import { renderInlineMarkdown } from './inline-markdown'
+
+function flattenText(node: React.ReactNode): string {
+ if (typeof node === 'string') return node
+ if (Array.isArray(node)) return node.map(flattenText).join('')
+ if (isValidElement<{ children?: React.ReactNode }>(node)) return flattenText(node.props.children)
+ return ''
+}
+
+function findByType(parts: React.ReactNode[], type: string): React.ReactNode {
+ return parts.find((p) => isValidElement(p) && p.type === type)
+}
+
+describe('renderInlineMarkdown', () => {
+ it('renders **bold** spans as strong elements', () => {
+ const parts = renderInlineMarkdown('The failing block is **ModalDenied** (a Slack block).')
+ const bold = findByType(parts, 'strong')
+ expect(bold).toBeDefined()
+ expect(flattenText(bold)).toBe('ModalDenied')
+ expect(flattenText(parts)).toBe('The failing block is ModalDenied (a Slack block).')
+ })
+
+ it('renders `code` spans as mono elements', () => {
+ const parts = renderInlineMarkdown('check the `webhook` payload')
+ expect(flattenText(findByType(parts, 'span'))).toBe('webhook')
+ expect(flattenText(parts)).toBe('check the webhook payload')
+ })
+
+ it('renders *italic* spans as em elements', () => {
+ const parts = renderInlineMarkdown('this is *important* context')
+ expect(flattenText(findByType(parts, 'em'))).toBe('important')
+ })
+
+ it('renders ***bold-italic*** as nested strong and em', () => {
+ const parts = renderInlineMarkdown('a ***wrapped*** word')
+ const bold = findByType(parts, 'strong')
+ expect(bold).toBeDefined()
+ expect(flattenText(parts)).toBe('a wrapped word')
+ })
+
+ it('renders links as their label text', () => {
+ const parts = renderInlineMarkdown('see [the docs](https://sim.ai/docs) for more')
+ expect(flattenText(parts)).toBe('see the docs for more')
+ })
+
+ it('keeps emphasis markers inside code spans verbatim', () => {
+ const parts = renderInlineMarkdown('pass `*args` and `**kwargs` through')
+ const codeTexts = parts
+ .filter((p) => isValidElement(p) && p.type === 'span')
+ .map((p) => flattenText(p))
+ expect(codeTexts).toEqual(['*args', '**kwargs'])
+ expect(flattenText(parts)).toBe('pass *args and **kwargs through')
+ })
+
+ it('renders nested markers inside emphasis and link labels', () => {
+ expect(
+ flattenText(renderInlineMarkdown('read [**the docs**](https://sim.ai/docs) first'))
+ ).toBe('read the docs first')
+ expect(flattenText(renderInlineMarkdown('use *`glob`* patterns'))).toBe('use glob patterns')
+ })
+
+ it('leaves unterminated markers verbatim', () => {
+ expect(renderInlineMarkdown('a **dangling marker')).toEqual(['a **dangling marker'])
+ expect(renderInlineMarkdown('a `dangling tick')).toEqual(['a `dangling tick'])
+ })
+
+ it('does not italicize bare asterisks in math-like text', () => {
+ expect(renderInlineMarkdown('2 * 3 * 4')).toEqual(['2 * 3 * 4'])
+ })
+
+ it('never reclassifies plain text the tokenizer rejected', () => {
+ expect(renderInlineMarkdown('* x *')).toEqual(['* x *'])
+ expect(renderInlineMarkdown('** spaced bullets **')).toEqual(['** spaced bullets **'])
+ })
+
+ it('passes plain text through untouched', () => {
+ expect(renderInlineMarkdown('no markup here.')).toEqual(['no markup here.'])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx
new file mode 100644
index 00000000000..591745ef329
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/inline-markdown.tsx
@@ -0,0 +1,56 @@
+import { Fragment, type ReactNode } from 'react'
+
+const INLINE_TOKEN =
+ /(\*{3}[^\s*](?:[^*\n]*[^\s*])?\*{3}|\*\*[^\s*](?:[^*\n]*[^\s*])?\*\*|\*[^\s*](?:[^*\n]*[^\s*])?\*|`[^`\n]+`|\[[^\]\n]+\]\([^\s)]+\))/g
+
+const LINK_TOKEN = /^\[([^\]\n]+)\]\([^\s)]+\)$/
+
+/**
+ * Minimal inline-markdown renderer for agent-group narration rows. Supports
+ * `**bold**`, `*italic*`, `***bold-italic***`, `` `code` `` spans, and
+ * `[label](url)` links (rendered as their label — narration is prose, not
+ * navigation). Emphasis contents and link labels are rendered recursively so
+ * nested markers resolve; code spans stay verbatim. Everything else,
+ * including unterminated markers, renders as-is. Full Streamdown rendering is
+ * intentionally avoided here — these rows re-render on every streaming frame.
+ *
+ * Splitting on a single capturing group alternates plain text (even indices)
+ * and matched tokens (odd indices), so index parity is the exact
+ * discriminator — plain text that merely resembles a marker (e.g. `* x *`,
+ * rejected by the tokenizer's boundary rules) is never reclassified.
+ */
+export function renderInlineMarkdown(text: string): ReactNode[] {
+ return text.split(INLINE_TOKEN).map((part, i) => (i % 2 === 1 ? renderToken(part, i) : part))
+}
+
+function renderToken(part: string, key: number): ReactNode {
+ if (part.length > 6 && part.startsWith('***') && part.endsWith('***')) {
+ return (
+
+ {renderInlineMarkdown(part.slice(3, -3))}
+
+ )
+ }
+ if (part.length > 4 && part.startsWith('**') && part.endsWith('**')) {
+ return (
+
+ {renderInlineMarkdown(part.slice(2, -2))}
+
+ )
+ }
+ if (part.length > 2 && part.startsWith('`') && part.endsWith('`')) {
+ return (
+
+ {part.slice(1, -1)}
+
+ )
+ }
+ if (part.length > 2 && part.startsWith('*') && part.endsWith('*')) {
+ return {renderInlineMarkdown(part.slice(1, -1))}
+ }
+ const link = LINK_TOKEN.exec(part)
+ if (link) {
+ return {renderInlineMarkdown(link[1])}
+ }
+ return part
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
index 59d9bf69dd7..90776627f57 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
@@ -1,30 +1,9 @@
import { useMemo } from 'react'
import { ShimmerText } from '@/components/ui'
import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
+import { getToolCompletedTitle } from '@/lib/copilot/tools/tool-display'
import type { ToolCallStatus } from '../../../../types'
-import { getToolIcon, resolveToolDisplayState } from '../../utils'
-
-function CircleCheck({ className }: { className?: string }) {
- return (
-
- )
-}
+import { resolveToolDisplayState } from '../../utils'
export function CircleStop({ className }: { className?: string }) {
return (
@@ -42,58 +21,6 @@ export function CircleStop({ className }: { className?: string }) {
)
}
-function Hyphen({ className }: { className?: string }) {
- return (
-
- )
-}
-
-function CircleOutline({ className }: { className?: string }) {
- return (
-
- )
-}
-
-function StatusIcon({ status, toolName }: { status: ToolCallStatus; toolName: string }) {
- const display = resolveToolDisplayState(status)
- if (display === 'spinner') {
- const Icon = getToolIcon(toolName)
- if (Icon) {
- return
- }
- return
- }
- if (display === 'cancelled') {
- return
- }
- if (display === 'interrupted') {
- return
- }
- const Icon = getToolIcon(toolName)
- if (Icon) {
- return
- }
- return
-}
-
interface ToolCallItemProps {
toolName: string
displayTitle: string
@@ -101,6 +28,12 @@ interface ToolCallItemProps {
streamingArgs?: string
}
+/**
+ * A single tool-call row inside an agent group: shimmer while executing, a
+ * static label once terminal. For `workspace_file` the title is derived live
+ * from the streaming args; because that path bypasses the completed-title
+ * rewrite in `toToolData`, the past-tense flip is applied here on success.
+ */
export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }: ToolCallItemProps) {
const liveWorkspaceFileTitle = useMemo(() => {
if (toolName !== WorkspaceFile.id || !streamingArgs) return null
@@ -132,13 +65,14 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }:
}, [toolName, streamingArgs])
const isExecuting = resolveToolDisplayState(status) === 'spinner'
- const title = liveWorkspaceFileTitle || displayTitle
+ const liveTitle = liveWorkspaceFileTitle || displayTitle
+ const title =
+ status === 'success' && liveWorkspaceFileTitle
+ ? (getToolCompletedTitle(liveTitle) ?? liveTitle)
+ : liveTitle
return (
-
-
-
-
+
{isExecuting ? (
{title}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
index b6a93bfbb34..20f778622db 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts
@@ -266,3 +266,123 @@ describe('shouldSmoothTextSegment', () => {
)
})
})
+
+describe('completed tool titles', () => {
+ function queryLogsCall(status: 'executing' | 'success' | 'error', displayTitle?: string) {
+ return {
+ type: 'tool_call' as const,
+ toolCall: { id: 't1', name: 'query_logs', status, displayTitle },
+ timestamp: 1,
+ }
+ }
+
+ function firstToolTitle(blocks: ContentBlock[]): string {
+ const segments = parseBlocks(blocks)
+ const group = segments.find((s) => s.type === 'agent_group')
+ if (!group || group.type !== 'agent_group') throw new Error('expected group')
+ const tool = group.items.find((i) => i.type === 'tool')
+ if (!tool || tool.type !== 'tool') throw new Error('expected tool')
+ return tool.data.displayTitle
+ }
+
+ it('rewrites query_logs to past tense on success', () => {
+ expect(firstToolTitle([queryLogsCall('success')])).toBe('Queried logs')
+ })
+
+ it('preserves the enriched workflow name in the past-tense title', () => {
+ expect(firstToolTitle([queryLogsCall('success', 'Querying logs for Invoice Bot')])).toBe(
+ 'Queried logs for Invoice Bot'
+ )
+ })
+
+ it('keeps present tense while executing and on error', () => {
+ expect(firstToolTitle([queryLogsCall('executing')])).toBe('Querying logs')
+ expect(firstToolTitle([queryLogsCall('error')])).toBe('Querying logs')
+ })
+})
+
+describe('narration text seams', () => {
+ it('inserts a space between glued consecutive blocks', () => {
+ const blocks: ContentBlock[] = [
+ subagentStart('research', 'S1', 'main'),
+ {
+ type: 'subagent_thinking',
+ content: 'that triggered it.',
+ spanId: 'S1',
+ subagent: 'research',
+ timestamp: 2,
+ },
+ {
+ type: 'subagent_text',
+ content: 'The failing block is X.',
+ spanId: 'S1',
+ subagent: 'research',
+ timestamp: 3,
+ },
+ ]
+ const segments = parseBlocks(blocks)
+ const group = segments.find((s) => s.type === 'agent_group')
+ if (!group || group.type !== 'agent_group') throw new Error('expected group')
+ const text = group.items.find((i) => i.type === 'text')
+ if (!text || text.type !== 'text') throw new Error('expected text')
+ expect(text.content).toBe('that triggered it. The failing block is X.')
+ })
+
+ it('never inserts a space into a segment split mid-word or mid-URL', () => {
+ const seam = (first: string, second: string): string => {
+ const blocks: ContentBlock[] = [
+ subagentStart('research', 'S1', 'main'),
+ { type: 'subagent_text', content: first, spanId: 'S1', subagent: 'research', timestamp: 2 },
+ {
+ type: 'subagent_text',
+ content: second,
+ spanId: 'S1',
+ subagent: 'research',
+ timestamp: 3,
+ },
+ ]
+ const segments = parseBlocks(blocks)
+ const group = segments.find((s) => s.type === 'agent_group')
+ if (!group || group.type !== 'agent_group') throw new Error('expected group')
+ const text = group.items.find((i) => i.type === 'text')
+ if (!text || text.type !== 'text') throw new Error('expected text')
+ return text.content
+ }
+
+ expect(seam('the fox jum', 'ps over')).toBe('the fox jumps over')
+ expect(seam('see https://example', '/path for details')).toBe(
+ 'see https://example/path for details'
+ )
+ expect(seam('日本語のテキストが分割', 'されても壊れない')).toBe(
+ '日本語のテキストが分割されても壊れない'
+ )
+ expect(seam('released in v2.', '1 last week')).toBe('released in v2.1 last week')
+ expect(seam('pi is 3.', '14 roughly')).toBe('pi is 3.14 roughly')
+ })
+
+ it('does not double-space when the seam already has whitespace', () => {
+ const blocks: ContentBlock[] = [
+ subagentStart('research', 'S1', 'main'),
+ {
+ type: 'subagent_text',
+ content: 'first sentence. ',
+ spanId: 'S1',
+ subagent: 'research',
+ timestamp: 2,
+ },
+ {
+ type: 'subagent_text',
+ content: 'second sentence.',
+ spanId: 'S1',
+ subagent: 'research',
+ timestamp: 3,
+ },
+ ]
+ const segments = parseBlocks(blocks)
+ const group = segments.find((s) => s.type === 'agent_group')
+ if (!group || group.type !== 'agent_group') throw new Error('expected group')
+ const text = group.items.find((i) => i.type === 'text')
+ if (!text || text.type !== 'text') throw new Error('expected text')
+ expect(text.content).toBe('first sentence. second sentence.')
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index e4286bf5e22..dadd78aab6e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -5,7 +5,11 @@ import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-ca
import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
-import { getToolDisplayTitle, humanizeToolName } from '@/lib/copilot/tools/tool-display'
+import {
+ getToolCompletedTitle,
+ getToolDisplayTitle,
+ humanizeToolName,
+} from '@/lib/copilot/tools/tool-display'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
import { SUBAGENT_LABELS } from '../../types'
@@ -101,8 +105,12 @@ function getOverrideDisplayTitle(tc: NonNullable): str
function toToolData(tc: NonNullable): ToolCallData {
const overrideDisplayTitle = getOverrideDisplayTitle(tc)
- const displayTitle =
+ const resolvedTitle =
overrideDisplayTitle || tc.displayTitle || getToolDisplayTitle(tc.name, tc.params)
+ const displayTitle =
+ tc.status === 'success'
+ ? (getToolCompletedTitle(resolvedTitle) ?? resolvedTitle)
+ : resolvedTitle
return {
id: tc.id,
@@ -129,13 +137,34 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment {
}
}
-function appendTextItem(group: AgentGroupSegment, content: string): void {
+type NarrationChannel = 'thinking' | 'assistant'
+
+/**
+ * Appends narration content to a group, merging into the previous text item.
+ * When a thinking run and a text run meet, their contents can glue together
+ * without any whitespace at the seam. The merge repairs only that semantic
+ * channel transition, and only at an unambiguous sentence boundary — trailing
+ * punctuation meeting a fresh alphanumeric start. Same-channel continuations
+ * (streamed chunks of one run, resume legs) are concatenated verbatim, so a
+ * token split like `v2.` + `1` is never mutated. `lastChannelByGroup` is the
+ * caller's per-parse tracker of each group's most recent narration channel.
+ */
+function appendTextItem(
+ group: AgentGroupSegment,
+ content: string,
+ channel: NarrationChannel,
+ lastChannelByGroup: Map
+): void {
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'text') {
- lastItem.content += content
+ const isChannelSeam = lastChannelByGroup.get(group) !== channel
+ const needsSpace =
+ isChannelSeam && /[.!?;:]$/.test(lastItem.content) && /^[A-Za-z0-9]/.test(content)
+ lastItem.content += (needsSpace ? ' ' : '') + content
} else {
group.items.push({ type: 'text', content })
}
+ lastChannelByGroup.set(group, channel)
}
/**
@@ -149,6 +178,7 @@ function appendTextItem(group: AgentGroupSegment, content: string): void {
function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsBySpanId = new Map()
+ const lastNarrationChannel = new Map()
// Stable per-run counters for React keys. The Nth top-level text run / Nth
// mothership group keeps the same key across re-parses (text runs and groups
// are append-only at the top level), so React never remounts the streaming
@@ -255,7 +285,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
}
if (!g) continue
g.isDelegating = false
- appendTextItem(g, block.content)
+ appendTextItem(
+ g,
+ block.content,
+ block.type === 'subagent_thinking' ? 'thinking' : 'assistant',
+ lastNarrationChannel
+ )
continue
}
@@ -271,7 +306,7 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
if (!g) g = ensureSpanGroup(block.subagent, block.spanId, block.parentSpanId)
if (g) {
g.isDelegating = false
- appendTextItem(g, block.content)
+ appendTextItem(g, block.content, 'assistant', lastNarrationChannel)
continue
}
}
@@ -400,6 +435,7 @@ export function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
const segments: MessageSegment[] = []
const groupsByKey = new Map()
+ const lastNarrationChannel = new Map()
let activeGroupKey: string | null = null
const groupKey = (name: string, parentToolCallId: string | undefined) =>
@@ -471,12 +507,12 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
const g = findGroupForSubagentChunk(block.parentToolCallId)
if (!g) continue
g.isDelegating = false
- const lastItem = g.items[g.items.length - 1]
- if (lastItem?.type === 'text') {
- lastItem.content += block.content
- } else {
- g.items.push({ type: 'text', content: block.content })
- }
+ appendTextItem(
+ g,
+ block.content,
+ block.type === 'subagent_thinking' ? 'thinking' : 'assistant',
+ lastNarrationChannel
+ )
continue
}
@@ -494,12 +530,7 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
const g = groupsByKey.get(resolveGroupKey(block.subagent, block.parentToolCallId))
if (g) {
g.isDelegating = false
- const lastItem = g.items[g.items.length - 1]
- if (lastItem?.type === 'text') {
- lastItem.content += block.content
- } else {
- g.items.push({ type: 'text', content: block.content })
- }
+ appendTextItem(g, block.content, 'assistant', lastNarrationChannel)
continue
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
index 6a06125c07a..767575849b9 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
@@ -71,11 +71,6 @@ export function getAgentIcon(name: string): IconComponent {
return TOOL_ICONS[name as keyof typeof TOOL_ICONS] ?? Blimp
}
-export function getToolIcon(name: string): IconComponent | undefined {
- const icon = TOOL_ICONS[name as keyof typeof TOOL_ICONS]
- return icon === Blimp ? undefined : icon
-}
-
export type MessagePhase = 'streaming' | 'revealing' | 'settled'
interface DeriveMessagePhaseArgs {
diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/copilot/tools/tool-display.test.ts
new file mode 100644
index 00000000000..353750417a2
--- /dev/null
+++ b/apps/sim/lib/copilot/tools/tool-display.test.ts
@@ -0,0 +1,58 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ getToolCompletedTitle,
+ getToolDisplayTitle,
+ humanizeToolName,
+} from '@/lib/copilot/tools/tool-display'
+
+describe('humanizeToolName', () => {
+ it('title-cases snake_case names', () => {
+ expect(humanizeToolName('manage_folder')).toBe('Manage Folder')
+ })
+
+ it('keeps canonical acronym casing', () => {
+ expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server')
+ expect(humanizeToolName('deploy_api')).toBe('Deploy API')
+ expect(humanizeToolName('oauth_request_access')).toBe('OAuth Request Access')
+ })
+})
+
+describe('getToolDisplayTitle natural-language coverage', () => {
+ it('gives gerund titles to tools that previously fell through to humanize', () => {
+ expect(getToolDisplayTitle('deploy_api')).toBe('Deploying API')
+ expect(getToolDisplayTitle('list_workspace_mcp_servers')).toBe('Listing MCP servers')
+ expect(getToolDisplayTitle('oauth_get_auth_link')).toBe('Getting authorization link')
+ expect(getToolDisplayTitle('diff_workflows')).toBe('Comparing workflows')
+ })
+
+ it('falls back to running code for function_execute without a title', () => {
+ expect(getToolDisplayTitle('function_execute')).toBe('Running code')
+ expect(getToolDisplayTitle('function_execute', { title: 'Crunching numbers' })).toBe(
+ 'Crunching numbers'
+ )
+ })
+})
+
+describe('getToolCompletedTitle', () => {
+ it('flips a leading gerund to past tense', () => {
+ expect(getToolCompletedTitle('Querying logs')).toBe('Queried logs')
+ expect(getToolCompletedTitle('Querying logs for Invoice Bot')).toBe(
+ 'Queried logs for Invoice Bot'
+ )
+ expect(getToolCompletedTitle('Searching online for pricing')).toBe(
+ 'Searched online for pricing'
+ )
+ expect(getToolCompletedTitle('Creating workflow')).toBe('Created workflow')
+ expect(getToolCompletedTitle('Running workflow')).toBe('Ran workflow')
+ expect(getToolCompletedTitle('Reading file')).toBe('Read file')
+ })
+
+ it('returns undefined for non-gerund titles', () => {
+ expect(getToolCompletedTitle('Run Agent')).toBeUndefined()
+ expect(getToolCompletedTitle('Folder action')).toBeUndefined()
+ expect(getToolCompletedTitle('Custom title from the model')).toBeUndefined()
+ })
+})
diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts
index 5ee25a69959..099503901d1 100644
--- a/apps/sim/lib/copilot/tools/tool-display.ts
+++ b/apps/sim/lib/copilot/tools/tool-display.ts
@@ -10,7 +10,7 @@ import { stripVersionSuffix } from '@sim/utils/string'
* enrichment for the run_* tools; every other surface (server persistence,
* transcript replay, fallback rendering) calls `getToolDisplayTitle` directly.
*
- * Icons are likewise client-owned — see `getToolIcon` in the message-content
+ * Icons are likewise client-owned — see `getAgentIcon` in the message-content
* utils. Nothing about tool presentation lives on the Go side anymore.
*/
@@ -87,6 +87,57 @@ const TOOL_TITLES: Record = {
generate_audio: 'Generating audio',
ffmpeg: 'Processing media',
manage_folder: 'Folder action',
+ check_deployment_status: 'Checking deployment status',
+ complete_scheduled_task: 'Completing scheduled task',
+ create_file: 'Creating file',
+ create_file_folder: 'Creating folder',
+ create_workspace_mcp_server: 'Creating MCP server',
+ delete_file: 'Deleting file',
+ delete_file_folder: 'Deleting folder',
+ delete_workflow: 'Deleting workflow',
+ delete_workspace_mcp_server: 'Deleting MCP server',
+ deploy_api: 'Deploying API',
+ deploy_chat: 'Deploying chat',
+ deploy_custom_block: 'Deploying custom block',
+ deploy_mcp: 'Deploying MCP server',
+ diff_workflows: 'Comparing workflows',
+ download_to_workspace_file: 'Downloading file',
+ function_execute: 'Running code',
+ generate_api_key: 'Generating API key',
+ get_block_outputs: 'Getting block outputs',
+ get_block_upstream_references: 'Getting block references',
+ get_deployed_workflow_state: 'Getting deployed workflow',
+ get_deployment_log: 'Getting deployment logs',
+ get_platform_actions: 'Getting platform actions',
+ get_scheduled_task_logs: 'Getting scheduled task logs',
+ get_workflow_data: 'Getting workflow data',
+ get_workflow_run_options: 'Getting run options',
+ list_file_folders: 'Listing folders',
+ list_integration_tools: 'Listing integrations',
+ list_user_workspaces: 'Listing workspaces',
+ list_workspace_mcp_servers: 'Listing MCP servers',
+ load_deployment: 'Loading deployment',
+ materialize_file: 'Preparing file',
+ move_file: 'Moving file',
+ move_file_folder: 'Moving folder',
+ move_workflow: 'Moving workflow',
+ oauth_get_auth_link: 'Getting authorization link',
+ oauth_request_access: 'Requesting access',
+ promote_to_live: 'Promoting to live',
+ redeploy: 'Redeploying',
+ rename_file: 'Renaming file',
+ rename_file_folder: 'Renaming folder',
+ rename_workflow: 'Renaming workflow',
+ restore_resource: 'Restoring resource',
+ run_block: 'Running block',
+ search_documentation: 'Searching documentation',
+ search_patterns: 'Searching patterns',
+ set_block_enabled: 'Toggling block',
+ set_environment_variables: 'Setting environment variables',
+ set_global_workflow_variables: 'Setting workflow variables',
+ update_deployment_version: 'Updating deployment',
+ update_scheduled_task_history: 'Updating task history',
+ update_workspace_mcp_server: 'Updating MCP server',
// Subagent trigger tools, when surfaced as a tool call.
workflow: 'Workflow Agent',
run: 'Run Agent',
@@ -101,6 +152,16 @@ const TOOL_TITLES: Record = {
superagent: 'Executing action',
}
+/** Acronyms that must keep their canonical casing when humanized. */
+const ACRONYM_CASING: Record = {
+ mcp: 'MCP',
+ api: 'API',
+ oauth: 'OAuth',
+ url: 'URL',
+ id: 'ID',
+ ai: 'AI',
+}
+
/**
* Final fallback: humanize a raw tool name (e.g. `manage_folder` -> "Manage
* Folder"), matching the legacy client humanizer so labels never render blank.
@@ -108,7 +169,9 @@ const TOOL_TITLES: Record = {
export function humanizeToolName(name: string): string {
const words = stripVersionSuffix(name).split('_').filter(Boolean)
if (words.length === 0) return name
- return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
+ return words
+ .map((word) => ACRONYM_CASING[word] ?? word.charAt(0).toUpperCase() + word.slice(1))
+ .join(' ')
}
/**
@@ -221,3 +284,65 @@ export function getToolDisplayTitle(name: string, args?: Record
return TOOL_TITLES[name] ?? humanizeToolName(name)
}
+
+/**
+ * Present-participle to past-tense verb map for completed tool titles. Applied
+ * to the leading word only, so "Searching online for X" -> "Searched online
+ * for X" while non-gerund labels ("Run Agent", "Folder action") pass through.
+ */
+const COMPLETED_VERB_REWRITES: Record = {
+ Accessing: 'Accessed',
+ Adding: 'Added',
+ Applying: 'Applied',
+ Checking: 'Checked',
+ Comparing: 'Compared',
+ Completing: 'Completed',
+ Crawling: 'Crawled',
+ Creating: 'Created',
+ Deleting: 'Deleted',
+ Deploying: 'Deployed',
+ Downloading: 'Downloaded',
+ Editing: 'Edited',
+ Executing: 'Executed',
+ Finding: 'Found',
+ Gathering: 'Gathered',
+ Generating: 'Generated',
+ Getting: 'Got',
+ Listing: 'Listed',
+ Loading: 'Loaded',
+ Managing: 'Managed',
+ Moving: 'Moved',
+ Opening: 'Opened',
+ Preparing: 'Prepared',
+ Processing: 'Processed',
+ Promoting: 'Promoted',
+ Querying: 'Queried',
+ Reading: 'Read',
+ Redeploying: 'Redeployed',
+ Renaming: 'Renamed',
+ Requesting: 'Requested',
+ Restoring: 'Restored',
+ Running: 'Ran',
+ Scraping: 'Scraped',
+ Searching: 'Searched',
+ Setting: 'Set',
+ Toggling: 'Toggled',
+ Updating: 'Updated',
+ Validating: 'Validated',
+ Writing: 'Wrote',
+}
+
+/**
+ * Rewrite a resolved display title to its past-tense form for a successfully
+ * completed tool call (e.g. "Querying logs for X" -> "Queried logs for X").
+ * Operates on the already-resolved title so enriched and persisted titles both
+ * work. Returns undefined when the title has no leading gerund rewrite — the
+ * caller keeps the original.
+ */
+export function getToolCompletedTitle(title: string): string | undefined {
+ const spaceIndex = title.indexOf(' ')
+ const firstWord = spaceIndex === -1 ? title : title.slice(0, spaceIndex)
+ const past = COMPLETED_VERB_REWRITES[firstWord]
+ if (!past) return undefined
+ return past + title.slice(firstWord.length)
+}