refactor(messages): extract content helpers (4 of 8) (#1901)

* refactor(messages): extract content helpers

* test(messages): cover extracted content helpers

* test(messages): statically import content helper

* test(compact): preserve assistant text helper semantics
This commit is contained in:
JATMN
2026-07-10 16:28:40 +08:00
committed by GitHub
parent 64d164d207
commit 9e06951e6e
4 changed files with 253 additions and 149 deletions
+1 -9
View File
@@ -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,
),
+16 -140
View File
@@ -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<Array<ContentBlockParam>>,
): 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'
+89
View File
@@ -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('<bash-input>git status</bash-input>')
expect(textForResubmit(message)).toEqual({
text: 'git status',
mode: 'bash',
})
})
test('textForResubmit extracts slash commands and strips IDE context from plain text', () => {
const commandMessage = userMessage(
'<command-name>review</command-name><command-args>pr 1901</command-args>',
)
expect(textForResubmit(commandMessage)).toEqual({
text: 'review pr 1901',
mode: 'prompt',
})
const plainMessage = userMessage(
'<ide_opened_file>/tmp/noise.ts</ide_opened_file>\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('<context>hidden</context>')).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('<command-name data-x="1">review</command-name>', 'command-name')).toBe(
'review',
)
expect(stripPromptXMLTags('<context>hidden</context>\nvisible')).toBe('visible')
})
+147
View File
@@ -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<Array<ContentBlockParam>>,
): string | null {
if (typeof content === 'string') {
return content
}
if (Array.isArray(content)) {
return extractTextContent(content, '\n').trim() || null
}
return null
}