diff --git a/src/services/compact/compact.test.ts b/src/services/compact/compact.test.ts index 57d288015..06e5d97be 100644 --- a/src/services/compact/compact.test.ts +++ b/src/services/compact/compact.test.ts @@ -357,15 +357,7 @@ function registerCommonCompactStubs(options: CompactMockOptions = {}) { uuid: `sys-${Math.random()}`, timestamp: new Date().toISOString(), })), - getAssistantMessageText: mock( - (msg: Message) => - typeof msg.message.content === 'string' - ? msg.message.content - : (Array.isArray(msg.message.content) && - msg.message.content[0]?.type === 'text') - ? msg.message.content[0].text - : '', - ), + getAssistantMessageText: _realMessagesModule.getAssistantMessageText, getLastAssistantMessage: mock( (msgs: Message[]) => msgs.findLast(m => m.type === 'assistant') ?? null, ), diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 2cefe7c81..55a66d61a 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -136,14 +136,18 @@ import { TASK_UPDATE_TOOL_NAME } from '../tools/TaskUpdateTool/constants.js' import type { PermissionMode } from '../types/permissions.js' import { normalizeToolInput, normalizeToolInputForAPI } from './api.js' import { logAntError, logForDebugging } from './debug.js' -import { stripIdeContextTags } from './displayTags.js' +import { hasEmbeddedSearchTools } from './embeddedTools.js' import { formatFileSize } from './format.js' import { validateImagesForAPI } from './imageValidation.js' import { safeParseJSON } from './json.js' import { logError, logMCPDebug } from './log.js' import { normalizeLegacyToolName } from './permissions/permissionRuleParser.js' import { isDangerousPermissionMode } from './permissions/PermissionMode.js' -import { escapeRegExp } from './stringUtils.js' +import { + getPlanModeV2AgentCount, + getPlanModeV2ExploreAgentCount, + isPlanModeInterviewPhaseEnabled, +} from './planModeV2.js' import { isTodoV2Enabled } from './tasks.js' import { CANCEL_MESSAGE, @@ -317,61 +321,16 @@ export { prepareUserContent, } from './messages/factories.js' -export function extractTag(html: string, tagName: string): string | null { - if (!html.trim() || !tagName.trim()) { - return null - } - - const escapedTag = escapeRegExp(tagName) - - // Create regex pattern that handles: - // 1. Self-closing tags - // 2. Tags with attributes - // 3. Nested tags of the same type - // 4. Multiline content - const pattern = new RegExp( - `<${escapedTag}(?:\\s+[^>]*)?>` + // Opening tag with optional attributes - '([\\s\\S]*?)' + // Content (non-greedy match) - `<\\/${escapedTag}>`, // Closing tag - 'gi', - ) - - let match - let depth = 0 - let lastIndex = 0 - const openingTag = new RegExp(`<${escapedTag}(?:\\s+[^>]*?)?>`, 'gi') - const closingTag = new RegExp(`<\\/${escapedTag}>`, 'gi') - - while ((match = pattern.exec(html)) !== null) { - // Check for nested tags - const content = match[1] - const beforeMatch = html.slice(lastIndex, match.index) - - // Reset depth counter - depth = 0 - - // Count opening tags before this match - openingTag.lastIndex = 0 - while (openingTag.exec(beforeMatch) !== null) { - depth++ - } - - // Count closing tags before this match - closingTag.lastIndex = 0 - while (closingTag.exec(beforeMatch) !== null) { - depth-- - } - - // Only include content if we're at the correct nesting level - if (depth === 0 && content) { - return content - } - - lastIndex = match.index + match[0].length - } - - return null -} +export { + extractTag, + extractTextContent, + getAssistantMessageText, + getContentText, + getUserMessageText, + isEmptyMessageText, + stripPromptXMLTags, + textForResubmit, +} from './messages/content.js' export function isNotEmptyMessage(message: Message): boolean { if ( @@ -2249,18 +2208,6 @@ export function normalizeContentFromAPI( }) } -export function isEmptyMessageText(text: string): boolean { - return ( - stripPromptXMLTags(text).trim() === '' || text.trim() === NO_CONTENT_MESSAGE - ) -} -const STRIPPED_TAGS_RE = - /<(commit_analysis|context|function_analysis|pr_analysis)>.*?<\/\1>\n?/gs - -export function stripPromptXMLTags(content: string): string { - return content.replace(STRIPPED_TAGS_RE, '').trim() -} - export function getToolUseID(message: NormalizedMessage): string | null { switch (message.type) { case 'attachment': @@ -2341,77 +2288,6 @@ export function filterUnresolvedToolUses(messages: Message[]): Message[] { }) } -export function getAssistantMessageText(message: Message): string | null { - if (message.type !== 'assistant') { - return null - } - - // For content blocks array, extract and concatenate text blocks - if (Array.isArray(message.message.content)) { - return ( - message.message.content - .filter(block => block.type === 'text') - .map(block => (block.type === 'text' ? block.text : '')) - .join('\n') - .trim() || null - ) - } - return null -} - -export function getUserMessageText( - message: Message | NormalizedMessage, -): string | null { - if (message.type !== 'user') { - return null - } - - const content = message.message.content - - return getContentText(content) -} - -export function textForResubmit( - msg: UserMessage, -): { text: string; mode: 'bash' | 'prompt' } | null { - const content = getUserMessageText(msg) - if (content === null) return null - const bash = extractTag(content, 'bash-input') - if (bash) return { text: bash, mode: 'bash' } - const cmd = extractTag(content, COMMAND_NAME_TAG) - if (cmd) { - const args = extractTag(content, COMMAND_ARGS_TAG) ?? '' - return { text: `${cmd} ${args}`, mode: 'prompt' } - } - return { text: stripIdeContextTags(content), mode: 'prompt' } -} - -/** - * Extract text from an array of content blocks, joining text blocks with the - * given separator. Works with ContentBlock, ContentBlockParam, BetaContentBlock, - * and their readonly/DeepImmutable variants via structural typing. - */ -export function extractTextContent( - blocks: readonly { readonly type: string }[], - separator = '', -): string { - return blocks - .filter((b): b is { type: 'text'; text: string } => b.type === 'text') - .map(b => b.text) - .join(separator) -} - -export function getContentText( - content: string | DeepImmutable>, -): string | null { - if (typeof content === 'string') { - return content - } - if (Array.isArray(content)) { - return extractTextContent(content, '\n').trim() || null - } - return null -} export { handleMessageFromStream } from './messages/streaming.js' export type { StreamingThinking, StreamingToolUse } from './messages/streaming.js' diff --git a/src/utils/messages/content.test.ts b/src/utils/messages/content.test.ts new file mode 100644 index 000000000..b25ff18e6 --- /dev/null +++ b/src/utils/messages/content.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from 'bun:test' +import { + extractTag, + extractTextContent, + getAssistantMessageText, + getContentText, + isEmptyMessageText, + stripPromptXMLTags, + textForResubmit, +} from './content.js' + +function userMessage(content: string) { + return { + type: 'user', + message: { content }, + } as never +} + +test('extractTextContent joins only text blocks', () => { + expect( + extractTextContent( + [ + { type: 'text', text: 'alpha' } as { type: string; text: string }, + { type: 'image' }, + { type: 'text', text: 'beta' } as { type: string; text: string }, + ], + '\n', + ), + ).toBe('alpha\nbeta') +}) + +test('getContentText returns null for array content without text', () => { + expect(getContentText([{ type: 'image' } as never])).toBeNull() +}) + +test('textForResubmit extracts bash-input commands', () => { + const message = userMessage('git status') + + expect(textForResubmit(message)).toEqual({ + text: 'git status', + mode: 'bash', + }) +}) + +test('textForResubmit extracts slash commands and strips IDE context from plain text', () => { + const commandMessage = userMessage( + 'reviewpr 1901', + ) + expect(textForResubmit(commandMessage)).toEqual({ + text: 'review pr 1901', + mode: 'prompt', + }) + + const plainMessage = userMessage( + '/tmp/noise.ts\nplease review this', + ) + expect(textForResubmit(plainMessage)).toEqual({ + text: 'please review this', + mode: 'prompt', + }) +}) + +test('isEmptyMessageText treats stripped tag-only and sentinel text as empty', () => { + expect(isEmptyMessageText('hidden')).toBe(true) + expect(isEmptyMessageText('(no content)')).toBe(true) + expect(isEmptyMessageText('hello')).toBe(false) +}) + +test('getAssistantMessageText joins text blocks for assistant messages', () => { + const message = { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'alpha' }, + { type: 'tool_use', id: 'toolu_1' }, + { type: 'text', text: 'beta' }, + ], + }, + } as never + + expect(getAssistantMessageText(message)).toBe('alpha\nbeta') +}) + +test('extractTag handles attributes and stripPromptXMLTags removes hidden blocks', () => { + expect(extractTag('review', 'command-name')).toBe( + 'review', + ) + expect(stripPromptXMLTags('hidden\nvisible')).toBe('visible') +}) diff --git a/src/utils/messages/content.ts b/src/utils/messages/content.ts new file mode 100644 index 000000000..be6491889 --- /dev/null +++ b/src/utils/messages/content.ts @@ -0,0 +1,147 @@ +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import type { DeepImmutable } from '../../types/utils.js' +import type { Message, NormalizedMessage, UserMessage } from '../../types/message.js' +import { COMMAND_ARGS_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js' +import { NO_CONTENT_MESSAGE } from '../../constants/messages.js' +import { stripIdeContextTags } from '../displayTags.js' +import { escapeRegExp } from '../stringUtils.js' + +export function extractTag(html: string, tagName: string): string | null { + if (!html.trim() || !tagName.trim()) { + return null + } + + const escapedTag = escapeRegExp(tagName) + + // Create regex pattern that handles: + // 1. Self-closing tags + // 2. Tags with attributes + // 3. Nested tags of the same type + // 4. Multiline content + const pattern = new RegExp( + `<${escapedTag}(?:\\s+[^>]*)?>` + // Opening tag with optional attributes + '([\\s\\S]*?)' + // Content (non-greedy match) + `<\\/${escapedTag}>`, // Closing tag + 'gi', + ) + + let match + let depth = 0 + let lastIndex = 0 + const openingTag = new RegExp(`<${escapedTag}(?:\\s+[^>]*?)?>`, 'gi') + const closingTag = new RegExp(`<\\/${escapedTag}>`, 'gi') + + while ((match = pattern.exec(html)) !== null) { + // Check for nested tags + const content = match[1] + const beforeMatch = html.slice(lastIndex, match.index) + + // Reset depth counter + depth = 0 + + // Count opening tags before this match + openingTag.lastIndex = 0 + while (openingTag.exec(beforeMatch) !== null) { + depth++ + } + + // Count closing tags before this match + closingTag.lastIndex = 0 + while (closingTag.exec(beforeMatch) !== null) { + depth-- + } + + // Only include content if we're at the correct nesting level + if (depth === 0 && content) { + return content + } + + lastIndex = match.index + match[0].length + } + + return null +} + +export function isEmptyMessageText(text: string): boolean { + return ( + stripPromptXMLTags(text).trim() === '' || text.trim() === NO_CONTENT_MESSAGE + ) +} +const STRIPPED_TAGS_RE = + /<(commit_analysis|context|function_analysis|pr_analysis)>.*?<\/\1>\n?/gs + +export function stripPromptXMLTags(content: string): string { + return content.replace(STRIPPED_TAGS_RE, '').trim() +} + +export function getAssistantMessageText(message: Message): string | null { + if (message.type !== 'assistant') { + return null + } + + // For content blocks array, extract and concatenate text blocks + if (Array.isArray(message.message.content)) { + return ( + message.message.content + .filter(block => block.type === 'text') + .map(block => (block.type === 'text' ? block.text : '')) + .join('\n') + .trim() || null + ) + } + return null +} + +export function getUserMessageText( + message: Message | NormalizedMessage, +): string | null { + if (message.type !== 'user') { + return null + } + + const content = message.message.content + + return getContentText(content) +} + +export function textForResubmit( + msg: UserMessage, +): { text: string; mode: 'bash' | 'prompt' } | null { + const content = getUserMessageText(msg) + if (content === null) return null + const bash = extractTag(content, 'bash-input') + if (bash) return { text: bash, mode: 'bash' } + const cmd = extractTag(content, COMMAND_NAME_TAG) + if (cmd) { + const args = extractTag(content, COMMAND_ARGS_TAG) ?? '' + return { text: `${cmd} ${args}`, mode: 'prompt' } + } + return { text: stripIdeContextTags(content), mode: 'prompt' } +} + +/** + * Extract text from an array of content blocks, joining text blocks with the + * given separator. Works with ContentBlock, ContentBlockParam, BetaContentBlock, + * and their readonly/DeepImmutable variants via structural typing. + */ +export function extractTextContent( + blocks: readonly { readonly type: string }[], + separator = '', +): string { + return blocks + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text) + .join(separator) +} + +export function getContentText( + content: string | DeepImmutable>, +): string | null { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + return extractTextContent(content, '\n').trim() || null + } + return null +}