mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
@@ -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
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user