/ utils / processUserInput / processTextPrompt.ts
processTextPrompt.ts
  1  import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'
  2  import { randomUUID } from 'crypto'
  3  import { setPromptId } from 'src/bootstrap/state.js'
  4  import type {
  5    AttachmentMessage,
  6    SystemMessage,
  7    UserMessage,
  8  } from 'src/types/message.js'
  9  import { logEvent } from '../../services/analytics/index.js'
 10  import type { PermissionMode } from '../../types/permissions.js'
 11  import { createUserMessage } from '../messages.js'
 12  import { logOTelEvent, redactIfDisabled } from '../telemetry/events.js'
 13  import { startInteractionSpan } from '../telemetry/sessionTracing.js'
 14  import {
 15    matchesKeepGoingKeyword,
 16    matchesNegativeKeyword,
 17  } from '../userPromptKeywords.js'
 18  
 19  export function processTextPrompt(
 20    input: string | Array<ContentBlockParam>,
 21    imageContentBlocks: ContentBlockParam[],
 22    imagePasteIds: number[],
 23    attachmentMessages: AttachmentMessage[],
 24    uuid?: string,
 25    permissionMode?: PermissionMode,
 26    isMeta?: boolean,
 27  ): {
 28    messages: (UserMessage | AttachmentMessage | SystemMessage)[]
 29    shouldQuery: boolean
 30  } {
 31    const promptId = randomUUID()
 32    setPromptId(promptId)
 33  
 34    const userPromptText =
 35      typeof input === 'string'
 36        ? input
 37        : input.find(block => block.type === 'text')?.text || ''
 38    startInteractionSpan(userPromptText)
 39  
 40    // Emit user_prompt OTEL event for both string (CLI) and array (SDK/VS Code)
 41    // input shapes. Previously gated on `typeof input === 'string'`, so VS Code
 42    // sessions never emitted user_prompt (anthropics/claude-code#33301).
 43    // For array input, use the LAST text block: createUserContent pushes the
 44    // user's message last (after any <ide_selection>/attachment context blocks),
 45    // so .findLast gets the actual prompt. userPromptText (first block) is kept
 46    // unchanged for startInteractionSpan to preserve existing span attributes.
 47    const otelPromptText =
 48      typeof input === 'string'
 49        ? input
 50        : input.findLast(block => block.type === 'text')?.text || ''
 51    if (otelPromptText) {
 52      void logOTelEvent('user_prompt', {
 53        prompt_length: String(otelPromptText.length),
 54        prompt: redactIfDisabled(otelPromptText),
 55        'prompt.id': promptId,
 56      })
 57    }
 58  
 59    const isNegative = matchesNegativeKeyword(userPromptText)
 60    const isKeepGoing = matchesKeepGoingKeyword(userPromptText)
 61    logEvent('tengu_input_prompt', {
 62      is_negative: isNegative,
 63      is_keep_going: isKeepGoing,
 64    })
 65  
 66    // If we have pasted images, create a message with image content
 67    if (imageContentBlocks.length > 0) {
 68      // Build content: text first, then images below
 69      const textContent =
 70        typeof input === 'string'
 71          ? input.trim()
 72            ? [{ type: 'text' as const, text: input }]
 73            : []
 74          : input
 75      const userMessage = createUserMessage({
 76        content: [...textContent, ...imageContentBlocks],
 77        uuid: uuid,
 78        imagePasteIds: imagePasteIds.length > 0 ? imagePasteIds : undefined,
 79        permissionMode,
 80        isMeta: isMeta || undefined,
 81      })
 82  
 83      return {
 84        messages: [userMessage, ...attachmentMessages],
 85        shouldQuery: true,
 86      }
 87    }
 88  
 89    const userMessage = createUserMessage({
 90      content: input,
 91      uuid,
 92      permissionMode,
 93      isMeta: isMeta || undefined,
 94    })
 95  
 96    return {
 97      messages: [userMessage, ...attachmentMessages],
 98      shouldQuery: true,
 99    }
100  }