mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-17 17:18:28 +08:00
623 lines
24 KiB
TypeScript
623 lines
24 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
FfmpegOperationValues,
|
|
ManageKnowledgeBaseOperationValues,
|
|
QueryUserTableOperationValues,
|
|
SaveUploadOperationValues,
|
|
SearchKnowledgeBaseOperationValues,
|
|
TOOL_CATALOG,
|
|
type ToolCatalogEntry,
|
|
UserTableOperationValues,
|
|
} from '@/lib/copilot/generated/tool-catalog-v1'
|
|
import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools'
|
|
import {
|
|
getToolCompletedTitle,
|
|
getToolDisplayTitle,
|
|
getToolStatusDisplayTitle,
|
|
getWaitCountdownTitle,
|
|
humanizeToolName,
|
|
mvDisplayVerb,
|
|
} from '@/lib/copilot/tools/tool-display'
|
|
|
|
function representativeToolArgs(entry: ToolCatalogEntry): Record<string, unknown> {
|
|
const args: Record<string, unknown> = {}
|
|
if (!entry.parameters || typeof entry.parameters !== 'object') return args
|
|
const properties = (entry.parameters as { properties?: unknown }).properties
|
|
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return args
|
|
|
|
for (const [key, rawSchema] of Object.entries(properties)) {
|
|
if (!rawSchema || typeof rawSchema !== 'object' || Array.isArray(rawSchema)) continue
|
|
const schema = rawSchema as { default?: unknown; enum?: unknown; type?: unknown }
|
|
if (schema.default !== undefined) {
|
|
args[key] = schema.default
|
|
} else if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
args[key] = schema.enum[0]
|
|
} else if (schema.type === 'boolean') {
|
|
args[key] = true
|
|
} else if (schema.type === 'object') {
|
|
args[key] = {}
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
function toolPropertyEnum(entry: ToolCatalogEntry, property: string): unknown[] {
|
|
if (!entry.parameters || typeof entry.parameters !== 'object') return []
|
|
const properties = (entry.parameters as { properties?: unknown }).properties
|
|
if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return []
|
|
const schema = (properties as Record<string, unknown>)[property]
|
|
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return []
|
|
const values = (schema as { enum?: unknown }).enum
|
|
return Array.isArray(values) ? values : []
|
|
}
|
|
|
|
describe('humanizeToolName', () => {
|
|
it('title-cases snake_case names', () => {
|
|
expect(humanizeToolName('manage_custom_tool')).toBe('Manage Custom Tool')
|
|
})
|
|
|
|
it('title-cases kebab-case names', () => {
|
|
expect(humanizeToolName('read-oauth-integrations')).toBe('Read OAuth Integrations')
|
|
})
|
|
|
|
it('keeps canonical acronym casing', () => {
|
|
expect(humanizeToolName('create_workspace_mcp_server')).toBe('Create Workspace MCP Server')
|
|
expect(humanizeToolName('deploy_as_api')).toBe('Deploy As 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_as_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 run_function without a title', () => {
|
|
expect(getToolDisplayTitle('run_function')).toBe('Running code')
|
|
expect(getToolDisplayTitle('run_function', { title: 'Crunching numbers' })).toBe(
|
|
'Crunching numbers'
|
|
)
|
|
})
|
|
|
|
it('has an intentional display title for every visible catalog tool', () => {
|
|
const hiddenToolNames = getHiddenToolNames()
|
|
const fallbackToolNames = Object.keys(TOOL_CATALOG).filter(
|
|
(name) => !hiddenToolNames.has(name) && getToolDisplayTitle(name) === humanizeToolName(name)
|
|
)
|
|
|
|
expect(fallbackToolNames).toEqual([])
|
|
})
|
|
|
|
it('has a completed-verb rewrite for every visible non-agent catalog tool', () => {
|
|
const hiddenToolNames = getHiddenToolNames()
|
|
const missingCompletedVerbs = Object.entries(TOOL_CATALOG).flatMap(([name, entry]) => {
|
|
if (entry.internal || hiddenToolNames.has(name)) return []
|
|
const title = getToolDisplayTitle(name, representativeToolArgs(entry))
|
|
return getToolCompletedTitle(title) ? [] : [`${name}: ${title}`]
|
|
})
|
|
|
|
expect(missingCompletedVerbs).toEqual([])
|
|
})
|
|
|
|
it('resolves every catalog action and operation enum without a generic placeholder', () => {
|
|
const genericPlaceholders = new Set([
|
|
'Credential action',
|
|
'Custom tool action',
|
|
'Editing file',
|
|
'Folder action',
|
|
'MCP server action',
|
|
'Managing knowledge base',
|
|
'Managing table',
|
|
'Preparing file',
|
|
'Processing media',
|
|
'Skill action',
|
|
])
|
|
const unresolvedVariants: string[] = []
|
|
|
|
for (const [name, entry] of Object.entries(TOOL_CATALOG)) {
|
|
for (const property of ['action', 'operation']) {
|
|
for (const value of toolPropertyEnum(entry, property)) {
|
|
const title = getToolDisplayTitle(name, {
|
|
...representativeToolArgs(entry),
|
|
[property]: value,
|
|
title: 'resource',
|
|
})
|
|
if (genericPlaceholders.has(title) || !getToolCompletedTitle(title)) {
|
|
unresolvedVariants.push(`${name}.${property}=${String(value)}: ${title}`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(unresolvedVariants).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for deployments', () => {
|
|
it.each([
|
|
['deploy_as_api', undefined, 'Deploying API'],
|
|
['deploy_as_api', { action: 'deploy' }, 'Deploying API'],
|
|
['deploy_as_api', { action: 'undeploy' }, 'Undeploying API'],
|
|
['deploy_as_chat', { action: 'deploy' }, 'Deploying chat'],
|
|
['deploy_as_chat', { action: 'undeploy' }, 'Undeploying chat'],
|
|
['publish_custom_block', { action: 'deploy' }, 'Publishing custom block'],
|
|
['publish_custom_block', { action: 'undeploy' }, 'Unpublishing custom block'],
|
|
['deploy_as_mcp', undefined, 'Deploying MCP tool'],
|
|
['redeploy', undefined, 'Redeploying API'],
|
|
])('uses the action and deployment type for %s', (toolName, args, expected) => {
|
|
expect(getToolDisplayTitle(toolName, args)).toBe(expected)
|
|
})
|
|
})
|
|
|
|
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')
|
|
expect(getToolCompletedTitle('Undeploying API')).toBe('Undeployed API')
|
|
expect(getToolCompletedTitle('Duplicating workflow')).toBe('Duplicated workflow')
|
|
expect(getToolCompletedTitle('Viewing custom tools')).toBe('Viewed custom tools')
|
|
expect(getToolCompletedTitle('Saving report.pdf')).toBe('Saved report.pdf')
|
|
})
|
|
|
|
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()
|
|
})
|
|
|
|
it('projects a terminal tense for every settled row, present tense only while running', () => {
|
|
expect(getToolStatusDisplayTitle('Comparing workflows', 'success')).toBe('Compared workflows')
|
|
expect(getToolStatusDisplayTitle('Comparing workflows', 'executing')).toBe(
|
|
'Comparing workflows'
|
|
)
|
|
// An errored row must not read as still running — the frozen present-tense
|
|
// title ("Searching for X" forever) was reported as a stuck tool call.
|
|
expect(getToolStatusDisplayTitle('Comparing workflows', 'error')).toBe(
|
|
'Failed comparing workflows'
|
|
)
|
|
expect(getToolStatusDisplayTitle('Searching for admin mentions', 'error')).toBe(
|
|
'Failed searching for admin mentions'
|
|
)
|
|
expect(getToolStatusDisplayTitle('Comparing workflows', 'cancelled')).toBe(
|
|
'Stopped comparing workflows'
|
|
)
|
|
// Non-gerund titles get a prefix rather than a bad rewrite.
|
|
expect(getToolStatusDisplayTitle('Read recent emails', 'error')).toBe(
|
|
'Failed: Read recent emails'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('mvDisplayVerb', () => {
|
|
it('reads a leaf-only change in the same folder as a rename', () => {
|
|
expect(mvDisplayVerb('workflows/falling-vacuum', 'workflows/failing-vacuum')).toBe('Renaming')
|
|
expect(mvDisplayVerb('files/Reports/a.md', 'files/Reports/b.md')).toBe('Renaming')
|
|
expect(mvDisplayVerb('tables/Leads', 'tables/Customers')).toBe('Renaming')
|
|
})
|
|
|
|
it('decodes segments so encoded sources compare against plain destinations', () => {
|
|
expect(mvDisplayVerb('workflows/My%20Flow', 'workflows/New Flow')).toBe('Renaming')
|
|
expect(mvDisplayVerb('files/My%20Docs/a.md', 'files/My Docs/b.md')).toBe('Renaming')
|
|
})
|
|
|
|
it('reads parent changes and folder destinations as moves', () => {
|
|
expect(mvDisplayVerb('files/a.png', 'files/Images/')).toBe('Moving')
|
|
expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/a.md')).toBe('Moving')
|
|
expect(mvDisplayVerb('files/Reports/a.md', 'files/Archive/b.md')).toBe('Moving')
|
|
expect(mvDisplayVerb('workflows/My Flow', 'workflows/Archive/')).toBe('Moving')
|
|
})
|
|
|
|
it('falls back to Moving when arguments are incomplete', () => {
|
|
expect(mvDisplayVerb(undefined, 'files/x.md')).toBe('Moving')
|
|
expect(mvDisplayVerb('files/x.md', undefined)).toBe('Moving')
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for the vfs verbs', () => {
|
|
it('shows the created file name', () => {
|
|
expect(
|
|
getToolDisplayTitle('create_empty_file', {
|
|
outputs: {
|
|
files: [{ path: 'files/Reports/Quarterly%20Report.pdf', mode: 'create' }],
|
|
},
|
|
})
|
|
).toBe('Creating Quarterly Report.pdf')
|
|
expect(getToolDisplayTitle('create_empty_file', { fileName: 'notes.md' })).toBe(
|
|
'Creating notes.md'
|
|
)
|
|
expect(
|
|
getToolDisplayTitle('create_empty_file', {
|
|
outputs: { files: [{ path: 'files/notes.md', mode: 'overwrite' }] },
|
|
})
|
|
).toBe('Overwriting notes.md')
|
|
expect(getToolDisplayTitle('create_empty_file')).toBe('Creating file')
|
|
})
|
|
|
|
it('titles rm from toolTitle, falling back to the paths', () => {
|
|
expect(getToolDisplayTitle('rm', { toolTitle: 'Old Report.pdf' })).toBe(
|
|
'Deleting Old Report.pdf'
|
|
)
|
|
// rm spans categories, so with no toolTitle the paths are the only signal.
|
|
expect(
|
|
getToolDisplayTitle('rm', {
|
|
paths: ['files/Old%20Reports', 'files/Drafts'],
|
|
})
|
|
).toBe('Deleting Old Reports and Drafts')
|
|
expect(
|
|
getToolDisplayTitle('rm', {
|
|
paths: ['workflows/Lead%20Router'],
|
|
})
|
|
).toBe('Deleting Lead Router')
|
|
// rm has no TOOL_TITLES entry (its case always returns), so a bare call
|
|
// must not fall through to the humanizer and render as "Rm".
|
|
expect(getToolDisplayTitle('rm', {})).toBe('Deleting resource')
|
|
expect(getToolDisplayTitle('rm')).toBe('Deleting resource')
|
|
})
|
|
|
|
it('uses the derived verb for mv titles', () => {
|
|
expect(
|
|
getToolDisplayTitle('mv', {
|
|
sources: ['workflows/falling-vacuum'],
|
|
destination: 'workflows/failing-vacuum',
|
|
toolTitle: 'falling-vacuum to failing-vacuum',
|
|
})
|
|
).toBe('Renaming falling-vacuum to failing-vacuum')
|
|
expect(
|
|
getToolDisplayTitle('mv', {
|
|
sources: ['files/a.png', 'files/b.png'],
|
|
destination: 'files/Images/',
|
|
toolTitle: '2 files to Images',
|
|
})
|
|
).toBe('Moving 2 files to Images')
|
|
})
|
|
|
|
it('titles cp and mkdir by intent', () => {
|
|
expect(getToolDisplayTitle('cp', { toolTitle: 'My Workflow' })).toBe('Duplicating My Workflow')
|
|
expect(getToolDisplayTitle('mkdir', { toolTitle: 'Reports/2026' })).toBe(
|
|
'Creating Reports/2026'
|
|
)
|
|
expect(getToolDisplayTitle('cp', {})).toBe('Duplicating workflow')
|
|
expect(getToolDisplayTitle('mkdir', {})).toBe('Creating folder')
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for workflow resources', () => {
|
|
it('shows workflow names for lifecycle actions', () => {
|
|
expect(getToolDisplayTitle('create_workflow', { name: 'Lead Router' })).toBe(
|
|
'Creating Lead Router'
|
|
)
|
|
expect(getToolDisplayTitle('edit_workflow', { workflowName: 'Lead Router' })).toBe(
|
|
'Editing Lead Router'
|
|
)
|
|
expect(
|
|
getToolDisplayTitle('rm', {
|
|
paths: ['workflows/Lead%20Router', 'workflows/Lead%20Enricher'],
|
|
})
|
|
).toBe('Deleting Lead Router and Lead Enricher')
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for managed resources', () => {
|
|
it.each([
|
|
[
|
|
'manage_custom_tool',
|
|
{
|
|
operation: 'add',
|
|
schema: { function: { name: 'lookupWeather' } },
|
|
},
|
|
'Creating lookupWeather',
|
|
],
|
|
['manage_mcp_connection', { operation: 'edit', config: { name: 'Linear' } }, 'Updating Linear'],
|
|
['manage_skill', { operation: 'delete', name: 'sales-research' }, 'Deleting sales-research'],
|
|
[
|
|
'manage_credential',
|
|
{
|
|
operation: 'rename',
|
|
previousDisplayName: 'Stripe',
|
|
displayName: 'Production Stripe',
|
|
},
|
|
'Renaming Stripe to Production Stripe',
|
|
],
|
|
['rm', { paths: ['workflows/Marketing/Q3%20Campaigns'] }, 'Deleting Q3 Campaigns'],
|
|
['manage_custom_tool', { operation: 'list' }, 'Viewing custom tools'],
|
|
['manage_mcp_connection', { operation: 'list' }, 'Viewing MCP servers'],
|
|
['manage_skill', { operation: 'list' }, 'Viewing skills'],
|
|
])('uses verb + resource name for %s', (toolName, args, expected) => {
|
|
expect(getToolDisplayTitle(toolName, args)).toBe(expected)
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for operation-driven tools', () => {
|
|
it('covers every FFmpeg operation with a specific activity', () => {
|
|
for (const operation of FfmpegOperationValues) {
|
|
expect(getToolDisplayTitle('ffmpeg', { operation })).not.toBe('Processing media')
|
|
}
|
|
expect(getToolDisplayTitle('ffmpeg', { operation: 'probe' })).toBe('Inspecting media')
|
|
expect(getToolDisplayTitle('ffmpeg', { operation: 'extract_audio' })).toBe('Extracting audio')
|
|
})
|
|
|
|
it('covers every knowledge-base operation with its actual verb and resource', () => {
|
|
for (const operation of ManageKnowledgeBaseOperationValues) {
|
|
expect(getToolDisplayTitle('manage_knowledge_base', { operation })).not.toBe(
|
|
'Managing knowledge base'
|
|
)
|
|
}
|
|
expect(getToolDisplayTitle('manage_knowledge_base', { operation: 'query' })).toBe(
|
|
'Searching knowledge base'
|
|
)
|
|
expect(getToolDisplayTitle('manage_knowledge_base', { operation: 'sync_connector' })).toBe(
|
|
'Syncing knowledge base connector'
|
|
)
|
|
})
|
|
|
|
it('covers every read-only table and knowledge-base operation', () => {
|
|
for (const operation of QueryUserTableOperationValues) {
|
|
expect(getToolDisplayTitle('query_user_table', { operation })).not.toBe('Query User Table')
|
|
}
|
|
for (const operation of SearchKnowledgeBaseOperationValues) {
|
|
expect(getToolDisplayTitle('search_knowledge_base', { operation })).not.toBe(
|
|
'Search Knowledge Base'
|
|
)
|
|
}
|
|
expect(getToolDisplayTitle('query_user_table', { operation: 'get_schema' })).toBe(
|
|
'Reading table schema'
|
|
)
|
|
expect(getToolDisplayTitle('search_knowledge_base', { operation: 'list_tags' })).toBe(
|
|
'Listing knowledge base tags'
|
|
)
|
|
})
|
|
|
|
it('covers every table operation with a specific activity', () => {
|
|
for (const operation of UserTableOperationValues) {
|
|
expect(getToolDisplayTitle('user_table', { operation })).not.toBe('Managing table')
|
|
}
|
|
expect(
|
|
getToolDisplayTitle('user_table', {
|
|
operation: 'rename_column',
|
|
args: { columnName: 'status', newName: 'stage' },
|
|
})
|
|
).toBe('Renaming column status to stage')
|
|
expect(getToolDisplayTitle('user_table', { operation: 'cancel_table_runs' })).toBe(
|
|
'Cancelling table runs'
|
|
)
|
|
})
|
|
|
|
it('distinguishes saving uploads from importing workflows', () => {
|
|
for (const operation of SaveUploadOperationValues) {
|
|
expect(
|
|
getToolDisplayTitle('save_upload', { operation, fileNames: ['Lead Router.json'] })
|
|
).not.toBe('Preparing file')
|
|
}
|
|
expect(
|
|
getToolDisplayTitle('save_upload', {
|
|
operation: 'save',
|
|
fileNames: ['Quarterly Report.pdf'],
|
|
})
|
|
).toBe('Saving Quarterly Report.pdf')
|
|
expect(
|
|
getToolDisplayTitle('save_upload', {
|
|
operation: 'import',
|
|
fileNames: ['Lead Router.json'],
|
|
})
|
|
).toBe('Importing Lead Router.json')
|
|
})
|
|
|
|
it('uses boolean and resource-type arguments where they change the action', () => {
|
|
expect(getToolDisplayTitle('set_block_enabled', { enabled: true })).toBe('Enabling block')
|
|
expect(getToolDisplayTitle('set_block_enabled', { enabled: false })).toBe('Disabling block')
|
|
expect(getToolDisplayTitle('restore_resource', { type: 'knowledgebase' })).toBe(
|
|
'Restoring knowledge base'
|
|
)
|
|
})
|
|
|
|
it('includes deployment versions when available', () => {
|
|
expect(getToolDisplayTitle('load_deployment', { version: 'live' })).toBe(
|
|
'Loading live deployment'
|
|
)
|
|
expect(getToolDisplayTitle('load_deployment', { version: '5' })).toBe(
|
|
'Loading deployment version 5'
|
|
)
|
|
expect(getToolDisplayTitle('promote_to_live', { version: 5 })).toBe(
|
|
'Promoting version 5 to live'
|
|
)
|
|
expect(getToolDisplayTitle('update_deployment_version', { version: 5 })).toBe(
|
|
'Updating deployment version 5'
|
|
)
|
|
})
|
|
|
|
it('uses the integration, variable scope, and nested variable operations', () => {
|
|
expect(getToolDisplayTitle('list_integration_tools', { integration: 'google_sheets' })).toBe(
|
|
'Listing Google Sheets tools'
|
|
)
|
|
expect(getToolDisplayTitle('set_environment_variables', { scope: 'personal' })).toBe(
|
|
'Setting personal environment variables'
|
|
)
|
|
expect(
|
|
getToolDisplayTitle('set_global_workflow_variables', {
|
|
operations: [{ operation: 'delete', name: 'OLD_URL' }],
|
|
})
|
|
).toBe('Deleting workflow variable OLD_URL')
|
|
expect(
|
|
getToolDisplayTitle('set_global_workflow_variables', {
|
|
operations: [
|
|
{ operation: 'add', name: 'API_URL' },
|
|
{ operation: 'edit', name: 'TIMEOUT' },
|
|
],
|
|
})
|
|
).toBe('Updating 2 workflow variables')
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for request-scoped MCP tools', () => {
|
|
it('hides the internal server id and humanizes the tool name', () => {
|
|
expect(getToolDisplayTitle('mcp-363de040-web_search_exa')).toBe('Web Search Exa')
|
|
expect(getToolDisplayTitle('mcp-363de040-read-oauth-integrations')).toBe(
|
|
'Read OAuth Integrations'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('getToolDisplayTitle for context management', () => {
|
|
it('describes compaction in user-facing language', () => {
|
|
expect(getToolDisplayTitle('context_compaction')).toBe('Summarizing context')
|
|
expect(getToolStatusDisplayTitle('Summarizing context', 'success')).toBe('Summarized context')
|
|
})
|
|
})
|
|
|
|
describe('getToolStatusDisplayTitle for browser takeover', () => {
|
|
it('uses a neutral completed title after browser control resumes', () => {
|
|
expect(
|
|
getToolStatusDisplayTitle(
|
|
'Waiting for you: Pick a match in the draw',
|
|
'success',
|
|
'browser_request_takeover'
|
|
)
|
|
).toBe('Resumed browser control')
|
|
})
|
|
})
|
|
|
|
describe('wait titles', () => {
|
|
// The row is on screen for the whole pause, so a bare "Wait" reads as a
|
|
// stall. The duration is the entire content of this tool.
|
|
it('names the duration it is pausing for', () => {
|
|
expect(getToolDisplayTitle('wait', { seconds: 30 })).toBe('Waiting 30s')
|
|
})
|
|
|
|
it('includes the reason when the model gives one', () => {
|
|
expect(getToolDisplayTitle('wait', { seconds: 15, reason: 'the test suite to finish' })).toBe(
|
|
'Waiting 15s for the test suite to finish'
|
|
)
|
|
})
|
|
|
|
it('degrades to a bare verb rather than printing a bogus duration', () => {
|
|
expect(getToolDisplayTitle('wait', {})).toBe('Waiting')
|
|
expect(getToolDisplayTitle('wait', { seconds: 0 })).toBe('Waiting')
|
|
expect(getToolDisplayTitle('wait', { seconds: 'soon' })).toBe('Waiting')
|
|
})
|
|
|
|
it('rounds a fractional duration instead of showing decimals', () => {
|
|
expect(getToolDisplayTitle('wait', { seconds: 2.4 })).toBe('Waiting 2s')
|
|
})
|
|
|
|
it('reads as past tense once the pause is over', () => {
|
|
expect(getToolCompletedTitle('Waiting 30s')).toBe('Waited 30s')
|
|
expect(getToolCompletedTitle('Waiting 15s for the test suite to finish')).toBe(
|
|
'Waited 15s for the test suite to finish'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('terminal titles', () => {
|
|
const call = (operation: string, args?: Record<string, unknown>) =>
|
|
getToolDisplayTitle('terminal', { operation, ...(args ? { args } : {}) })
|
|
|
|
it('names the command being run', () => {
|
|
expect(call('run', { command: 'bun test' })).toBe('Running bun test')
|
|
})
|
|
|
|
it('titles each operation rather than reading as a bare "Terminal"', () => {
|
|
expect(call('read')).toBe('Reading terminal')
|
|
expect(call('input')).toBe('Typing into terminal')
|
|
expect(call('kill')).toBe('Stopping command')
|
|
expect(call('list')).toBe('Listing terminals')
|
|
expect(call('new')).toBe('Opening terminal')
|
|
expect(call('panes')).toBe('Listing tmux panes')
|
|
})
|
|
|
|
it('collapses newlines so a multi-line command stays one row', () => {
|
|
expect(call('run', { command: 'cd apps/sim\n bun test' })).toBe('Running cd apps/sim bun test')
|
|
})
|
|
|
|
it('truncates a long command rather than wrapping the row', () => {
|
|
const title = call('run', { command: 'echo '.repeat(40) })
|
|
expect(title.length).toBeLessThanOrEqual('Running '.length + 48)
|
|
expect(title.endsWith('…')).toBe(true)
|
|
})
|
|
|
|
it('falls back to a generic label before the arguments have streamed in', () => {
|
|
expect(call('run')).toBe('Running command')
|
|
expect(getToolDisplayTitle('terminal', {})).toBe('Using terminal')
|
|
})
|
|
|
|
it('names what the user has to do when the terminal is handed over', () => {
|
|
expect(call('handoff', { reason: 'Enter your sudo password' })).toBe(
|
|
'Waiting for you: Enter your sudo password'
|
|
)
|
|
expect(call('handoff')).toBe('Waiting for you in the terminal')
|
|
})
|
|
|
|
it('still titles rows from before the tools were consolidated', () => {
|
|
// Persisted transcripts reference the old one-tool-per-operation names.
|
|
expect(getToolDisplayTitle('terminal_run', { command: 'bun test' })).toBe('Running bun test')
|
|
expect(getToolDisplayTitle('terminal_read')).toBe('Reading terminal')
|
|
})
|
|
|
|
it('reads as past tense once each terminal action settles', () => {
|
|
expect(getToolCompletedTitle('Running bun test')).toBe('Ran bun test')
|
|
expect(getToolCompletedTitle('Stopping command')).toBe('Stopped command')
|
|
expect(getToolCompletedTitle('Reading terminal')).toBe('Read terminal')
|
|
expect(getToolCompletedTitle('Using terminal')).toBe('Used terminal')
|
|
})
|
|
})
|
|
|
|
describe('wait countdown', () => {
|
|
const args = { seconds: 30, reason: 'Claude Code to finish the summary' }
|
|
|
|
it('starts at the full duration', () => {
|
|
expect(getWaitCountdownTitle(args, 0)).toBe('Waiting 30s for Claude Code to finish the summary')
|
|
})
|
|
|
|
it('counts down as the pause runs', () => {
|
|
expect(getWaitCountdownTitle(args, 5_000)).toBe(
|
|
'Waiting 25s for Claude Code to finish the summary'
|
|
)
|
|
expect(getWaitCountdownTitle(args, 29_000)).toBe(
|
|
'Waiting 1s for Claude Code to finish the summary'
|
|
)
|
|
})
|
|
|
|
it('holds a whole second rather than ticking on partial ones', () => {
|
|
expect(getWaitCountdownTitle(args, 999)).toBe(getWaitCountdownTitle(args, 0))
|
|
expect(getWaitCountdownTitle(args, 1_001)).toBe(getWaitCountdownTitle(args, 1_999))
|
|
})
|
|
|
|
it('drops the number at zero instead of freezing on "0s"', () => {
|
|
// The row can outlive its own countdown while the turn picks back up, and
|
|
// a stuck "0s" reads as broken.
|
|
expect(getWaitCountdownTitle(args, 30_000)).toBe(
|
|
'Waiting for Claude Code to finish the summary'
|
|
)
|
|
expect(getWaitCountdownTitle(args, 90_000)).toBe(
|
|
'Waiting for Claude Code to finish the summary'
|
|
)
|
|
})
|
|
|
|
it('never counts up from a clock that jumped backwards', () => {
|
|
expect(getWaitCountdownTitle(args, -5_000)).toBe(
|
|
'Waiting 30s for Claude Code to finish the summary'
|
|
)
|
|
})
|
|
|
|
it('stays a bare verb when there is no duration to count', () => {
|
|
expect(getWaitCountdownTitle({}, 3_000)).toBe('Waiting')
|
|
})
|
|
|
|
it('matches the static title at zero elapsed', () => {
|
|
expect(getWaitCountdownTitle(args, 0)).toBe(getToolDisplayTitle('wait', args))
|
|
})
|
|
})
|