Files
claude-code/src/utils/handlePromptSubmit.ts
unraid 189766c5af fixup: address CodeRabbit second-round review on PR #386
Four inline + one outside-diff actionable comment from the second CodeRabbit
review on claude-code-best/claude-code#386:

- tests/mocks/auth.ts: align mock return contracts with src/utils/auth.ts.
  checkAndRefreshOAuthTokenIfNeeded resolves to a Promise<boolean> and
  getClaudeAIOAuthTokens returns the full token shape (refreshToken, expiresAt,
  scopes, subscriptionType, rateLimitTier) so tests that branch on these
  values can not silently drift away from production.
- src/utils/handlePromptSubmit.ts (461-468): clear the freshly-published
  abortController before the early return when every claimed autonomy command
  was skipped as non-consumable, so this turn's stale controller does not leak
  into the next turn.
- src/utils/handlePromptSubmit.ts (621-649): separate execution failure from
  finalizer failure. The turn body now writes to a `turnError` slot; a single
  pass after the inner try decides whether to finalize claimed commands as
  `completed` or `failed`, with each finalize call wrapped in its own
  try/catch so a failure inside finalize does not flip a successful turn into
  `failed` and double-finalize the same commands. The outer catch only
  rethrows the original turn error.
- src/utils/processUserInput/processSlashCommand.tsx (228-276): wrap the
  post-success `finalizeDeferredAutonomyRunCompleted()` call in its own
  try/catch so a finalize failure no longer falls into the worker-failure
  catch path and emits a contradictory `<scheduled-task-result status="failed">`
  for a slash command that actually succeeded.

Outside scope (not changed) — the CodeRabbit suggestion to add a `.ts`
extension to the shared `tests/mocks/auth` import contradicts the project's
existing convention: every other test imports the shared mocks without the
extension (e.g. `tests/mocks/log`, `tests/mocks/debug`,
`tests/mocks/file-system`), and the project's tsconfig does not enable
`allowImportingTsExtensions`, so adding the extension fails typecheck. The
import is kept extension-less to match the rest of the suite.

Validation:
- bun run typecheck (clean).
- bun test → 3996 pass / 0 fail across 305 test files.
2026-04-29 15:49:54 +08:00

689 lines
24 KiB
TypeScript

import type { UUID } from 'crypto'
import { logEvent } from 'src/services/analytics/index.js'
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
import { type Command, getCommandName, isCommandEnabled } from '../commands.js'
import { selectableUserMessagesFilter } from '../components/MessageSelector.js'
import type { SpinnerMode } from '../components/Spinner/types.js'
import type { QuerySource } from '../constants/querySource.js'
import { expandPastedTextRefs, parseReferences } from '../history.js'
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
import type { IDESelection } from '../hooks/useIdeSelection.js'
import type { AppState } from '../state/AppState.js'
import type { SetToolJSXFn } from '../Tool.js'
import type { LocalJSXCommandOnDone } from '../types/command.js'
import type { Message } from '../types/message.js'
import {
isValidImagePaste,
type PromptInputMode,
type QueuedCommand,
} from '../types/textInputTypes.js'
import { createAbortController } from './abortController.js'
import type { PastedContent } from './config.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import type { EffortValue } from './effort.js'
import type { FileHistoryState } from './fileHistory.js'
import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
import { gracefulShutdownSync } from './gracefulShutdown.js'
import { toError } from './errors.js'
import { logError } from './log.js'
import { enqueue } from './messageQueueManager.js'
import { resolveSkillModelOverride } from './model/model.js'
import {
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from './autonomyQueueLifecycle.js'
import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
import { processUserInput } from './processUserInput/processUserInput.js'
import type { QueryGuard } from './QueryGuard.js'
import { queryCheckpoint, startQueryProfile } from './queryProfiler.js'
import { runWithWorkload } from './workloadContext.js'
function exit(): void {
gracefulShutdownSync(0)
}
type BaseExecutionParams = {
queuedCommands?: QueuedCommand[]
messages: Message[]
mainLoopModel: string
ideSelection: IDESelection | undefined
querySource: QuerySource
commands: Command[]
queryGuard: QueryGuard
/**
* True when external loading (remote session, foregrounded background task)
* is active. These don't route through queryGuard, so the queue check must
* account for them separately. Omit (defaults to false) for the dequeue path
* (executeQueuedInput) — dequeued items were already queued past this check.
*/
isExternalLoading?: boolean
setToolJSX: SetToolJSXFn
getToolUseContext: (
messages: Message[],
newMessages: Message[],
abortController: AbortController,
mainLoopModel: string,
) => ProcessUserInputContext
setUserInputOnProcessing: (prompt?: string) => void
setAbortController: (abortController: AbortController | null) => void
onQuery: (
newMessages: Message[],
abortController: AbortController,
shouldQuery: boolean,
additionalAllowedTools: string[],
mainLoopModel: string,
onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>,
input?: string,
effort?: EffortValue,
) => Promise<boolean>
setAppState: (updater: (prev: AppState) => AppState) => void
onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>
canUseTool?: CanUseToolFn
}
/**
* Parameters for core execution logic (no UI concerns).
*/
type ExecuteUserInputParams = BaseExecutionParams & {
resetHistory: () => void
onInputChange: (value: string) => void
}
export type PromptInputHelpers = {
setCursorOffset: (offset: number) => void
clearBuffer: () => void
resetHistory: () => void
}
export type HandlePromptSubmitParams = BaseExecutionParams & {
// Direct user input path (set when called from onSubmit, absent for queue processor)
input?: string
mode?: PromptInputMode
pastedContents?: Record<number, PastedContent>
helpers: PromptInputHelpers
onInputChange: (value: string) => void
setPastedContents: React.Dispatch<
React.SetStateAction<Record<number, PastedContent>>
>
abortController?: AbortController | null
addNotification?: (notification: {
key: string
text: string
priority: 'low' | 'medium' | 'high' | 'immediate'
}) => void
setMessages?: (updater: (prev: Message[]) => Message[]) => void
streamMode?: SpinnerMode
hasInterruptibleToolInProgress?: boolean
uuid?: UUID
/**
* When true, input starting with `/` is treated as plain text.
* Used for remotely-received messages (bridge/CCR) that should not
* trigger local slash commands or skills.
*/
skipSlashCommands?: boolean
/** Preserves that the input originated from Remote Control when queued. */
bridgeOrigin?: boolean
}
export async function handlePromptSubmit(
params: HandlePromptSubmitParams,
): Promise<void> {
const {
helpers,
queryGuard,
isExternalLoading = false,
commands,
onInputChange,
setPastedContents,
setToolJSX,
getToolUseContext,
messages,
mainLoopModel,
ideSelection,
setUserInputOnProcessing,
setAbortController,
onQuery,
setAppState,
onBeforeQuery,
canUseTool,
queuedCommands,
uuid,
skipSlashCommands,
bridgeOrigin,
} = params
const { setCursorOffset, clearBuffer, resetHistory } = helpers
// Queue processor path: commands are pre-validated and ready to execute.
// Skip all input validation, reference parsing, and queuing logic.
if (queuedCommands?.length) {
startQueryProfile()
await executeUserInput({
queuedCommands,
messages,
mainLoopModel,
ideSelection,
querySource: params.querySource,
commands,
queryGuard,
setToolJSX,
getToolUseContext,
setUserInputOnProcessing,
setAbortController,
onQuery,
setAppState,
onBeforeQuery,
resetHistory,
canUseTool,
onInputChange,
})
return
}
const input = params.input ?? ''
const mode = params.mode ?? 'prompt'
const rawPastedContents = params.pastedContents ?? {}
// Images are only sent if their [Image #N] placeholder is still in the text.
// Deleting the inline pill drops the image; orphaned entries are filtered here.
const referencedIds = new Set(parseReferences(input).map(r => r.id))
const pastedContents = Object.fromEntries(
Object.entries(rawPastedContents).filter(
([, c]) => c.type !== 'image' || referencedIds.has(c.id),
),
)
const hasImages = Object.values(pastedContents).some(isValidImagePaste)
if (input.trim() === '') {
return
}
// Handle exit commands by triggering the exit command instead of direct process.exit
// Skip for remote bridge messages — "exit" typed on iOS shouldn't kill the local session
if (
!skipSlashCommands &&
['exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(input.trim())
) {
// Trigger the exit command which will show the feedback dialog
const exitCommand = commands.find(cmd => cmd.name === 'exit')
if (exitCommand) {
// Submit the /exit command instead - recursive call needs to be handled
void handlePromptSubmit({
...params,
input: '/exit',
})
} else {
// Fallback to direct exit if exit command not found
exit()
}
return
}
// Parse references and replace with actual content early, before queueing
// or immediate-command dispatch, so queued commands and immediate commands
// both receive the expanded text from when it was submitted.
const finalInput = expandPastedTextRefs(input, pastedContents)
const pastedTextRefs = parseReferences(input).filter(
r => pastedContents[r.id]?.type === 'text',
)
const pastedTextCount = pastedTextRefs.length
const pastedTextBytes = pastedTextRefs.reduce(
(sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0),
0,
)
logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes })
// Handle local-jsx immediate commands (e.g., /config, /doctor)
// Skip for remote bridge messages — slash commands from CCR clients are plain text
if (!skipSlashCommands && finalInput.trim().startsWith('/')) {
const trimmedInput = finalInput.trim()
const spaceIndex = trimmedInput.indexOf(' ')
const commandName =
spaceIndex === -1
? trimmedInput.slice(1)
: trimmedInput.slice(1, spaceIndex)
const commandArgs =
spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim()
const immediateCommand = commands.find(
cmd =>
cmd.immediate &&
isCommandEnabled(cmd) &&
(cmd.name === commandName ||
cmd.aliases?.includes(commandName) ||
getCommandName(cmd) === commandName),
)
if (
immediateCommand &&
immediateCommand.type === 'local-jsx' &&
(queryGuard.isActive || isExternalLoading)
) {
logEvent('tengu_immediate_command_executed', {
commandName:
immediateCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// Clear input
onInputChange('')
setCursorOffset(0)
setPastedContents({})
clearBuffer()
const context = getToolUseContext(
messages,
[],
createAbortController(),
mainLoopModel,
)
let doneWasCalled = false
const onDone: LocalJSXCommandOnDone = (result, options) => {
doneWasCalled = true
// Use clearLocalJSX to explicitly clear the local JSX command
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
if (result && options?.display !== 'skip' && params.addNotification) {
params.addNotification({
key: `immediate-${immediateCommand.name}`,
text: result,
priority: 'immediate',
})
}
if (options?.nextInput) {
if (options.submitNextInput) {
enqueue({ value: options.nextInput, mode: 'prompt' })
} else {
onInputChange(options.nextInput)
}
}
}
const impl = await immediateCommand.load()
const jsx = await impl.call(onDone, context, commandArgs)
// Skip if onDone already fired — prevents stuck isLocalJSXCommand
// (see processSlashCommand.tsx local-jsx case for full mechanism).
if (jsx && !doneWasCalled) {
setToolJSX({
jsx,
shouldHidePromptInput: false,
isLocalJSXCommand: true,
isImmediate: true,
})
}
return
}
}
if (queryGuard.isActive || isExternalLoading) {
// Only allow prompt and bash mode commands to be queued
if (mode !== 'prompt' && mode !== 'bash') {
return
}
// Interrupt the current turn when all executing tools have
// interruptBehavior 'cancel' (e.g. SleepTool).
if (params.hasInterruptibleToolInProgress) {
logForDebugging(
`[interrupt] Aborting current turn: streamMode=${params.streamMode}`,
)
logEvent('tengu_cancel', {
source:
'interrupt_on_submit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
streamMode:
params.streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
params.abortController?.abort('interrupt')
}
// Enqueue with string value + raw pastedContents. Images will be resized
// at execution time when processUserInput runs (not baked in here).
enqueue({
value: finalInput.trim(),
preExpansionValue: input.trim(),
mode,
pastedContents: hasImages ? pastedContents : undefined,
skipSlashCommands,
bridgeOrigin,
uuid,
})
onInputChange('')
setCursorOffset(0)
setPastedContents({})
resetHistory()
clearBuffer()
return
}
// Start query profiling for this query
startQueryProfile()
// Construct a QueuedCommand from the direct user input so both paths
// go through the same executeUserInput loop. This ensures images get
// resized via processUserInput regardless of how the command arrives.
const cmd: QueuedCommand = {
value: finalInput,
preExpansionValue: input,
mode,
pastedContents: hasImages ? pastedContents : undefined,
skipSlashCommands,
bridgeOrigin,
uuid,
}
await executeUserInput({
queuedCommands: [cmd],
messages,
mainLoopModel,
ideSelection,
querySource: params.querySource,
commands,
queryGuard,
setToolJSX,
getToolUseContext,
setUserInputOnProcessing,
setAbortController,
onQuery,
setAppState,
onBeforeQuery,
resetHistory,
canUseTool,
onInputChange,
})
}
/**
* Core logic for executing user input without UI side effects.
*
* All commands arrive as `queuedCommands`. First command gets full treatment
* (attachments, ideSelection, pastedContents with image resizing). Commands 2-N
* get `skipAttachments` to avoid duplicating turn-level context.
*/
async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
const {
messages,
mainLoopModel,
ideSelection,
querySource,
queryGuard,
setToolJSX,
getToolUseContext,
setUserInputOnProcessing,
setAbortController,
onQuery,
setAppState,
onBeforeQuery,
resetHistory,
canUseTool,
queuedCommands,
} = params
// Note: paste references are already processed before calling this function
// (either in handlePromptSubmit before queuing, or before initial execution).
// Always create a fresh abort controller — queryGuard guarantees no concurrent
// executeUserInput call, so there's no prior controller to inherit.
const abortController = createAbortController()
setAbortController(abortController)
function makeContext(): ProcessUserInputContext {
return getToolUseContext(messages, [], abortController, mainLoopModel)
}
// Wrap in try-finally so the guard is released even if processUserInput
// throws or onQuery is skipped. onQuery's finally calls queryGuard.end(),
// which transitions running→idle; cancelReservation() below is a no-op in
// that case (only acts on dispatching state).
try {
// Reserve the guard BEFORE processUserInput — processBashCommand awaits
// BashTool.call() and processSlashCommand awaits getMessagesForSlashCommand,
// so the guard must be active during those awaits to ensure concurrent
// handlePromptSubmit calls queue (via the isActive check above) instead
// of starting a second executeUserInput. This call is a no-op if the
// guard is already in dispatching (legacy queue-processor path).
queryGuard.reserve()
queryCheckpoint('query_process_user_input_start')
const newMessages: Message[] = []
let shouldQuery = false
let allowedTools: string[] | undefined
let model: string | undefined
let effort: EffortValue | undefined
let nextInput: string | undefined
let submitNextInput: boolean | undefined
// Iterate all commands uniformly. First command gets attachments +
// ideSelection + pastedContents, rest skip attachments to avoid
// duplicating turn-level context (IDE selection, todos, diffs).
let commands = queuedCommands ?? []
const queuedAutonomyClaim =
await claimConsumableQueuedAutonomyCommands(commands)
commands = queuedAutonomyClaim.attachmentCommands
const claimedAutonomyCommands = queuedAutonomyClaim.claimedCommands
if (commands.length === 0) {
// Clear the abort controller published a few lines above so this turn's
// stale controller does not leak into the next turn when every claimed
// autonomy command was skipped as non-consumable.
setAbortController(null)
return
}
// Compute the workload tag for this turn. queueProcessor can batch a
// cron prompt with a same-tick human prompt; only tag when EVERY
// command agrees on the same non-undefined workload — a human in the
// mix is actively waiting.
const firstWorkload = commands[0]?.workload
const turnWorkload =
firstWorkload !== undefined &&
commands.every(c => c.workload === firstWorkload)
? firstWorkload
: undefined
const deferredAutonomyRunIds = new Set<string>()
// Wrap the entire turn (processUserInput loop + onQuery) in an
// AsyncLocalStorage context. This is the ONLY way to correctly
// propagate workload across await boundaries: void-detached bg agents
// (executeForkedSlashCommand, AgentTool) capture the ALS context at
// invocation time, and every await inside them resumes in that
// context — isolated from the parent's continuation. A process-global
// mutable slot would be clobbered at the detached closure's first
// await by this function's synchronous return path. See state.ts.
let turnError: unknown
try {
await runWithWorkload(turnWorkload, async () => {
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
const isFirst = i === 0
const runId = cmd.autonomy?.runId
const result = await processUserInput({
input: cmd.value,
preExpansionInput: cmd.preExpansionValue,
mode: cmd.mode,
setToolJSX,
context: makeContext(),
pastedContents: isFirst ? cmd.pastedContents : undefined,
messages,
setUserInputOnProcessing: isFirst
? setUserInputOnProcessing
: undefined,
isAlreadyProcessing: !isFirst,
querySource,
canUseTool,
uuid: cmd.uuid,
ideSelection: isFirst ? ideSelection : undefined,
skipSlashCommands: cmd.skipSlashCommands,
bridgeOrigin: cmd.bridgeOrigin,
isMeta: cmd.isMeta,
skipAttachments: !isFirst,
autonomy: cmd.autonomy,
})
if (runId && result.deferAutonomyCompletion) {
deferredAutonomyRunIds.add(runId)
}
// Stamp origin here rather than threading another arg through
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
// Derive origin from mode for task-notifications — mirrors the origin
// derivation at messages.ts (case 'queued_command'); intentionally
// does NOT mirror its isMeta:true so idle-dequeued notifications stay
// visible in the transcript via UserAgentNotificationMessage.
const origin =
cmd.origin ??
(cmd.mode === 'task-notification'
? ({ kind: 'task-notification' } as const)
: undefined)
if (origin) {
for (const m of result.messages) {
if (m.type === 'user') m.origin = origin
}
}
newMessages.push(...result.messages)
if (isFirst) {
shouldQuery = result.shouldQuery
allowedTools = result.allowedTools
model = result.model
effort = result.effort
nextInput = result.nextInput
submitNextInput = result.submitNextInput
}
}
queryCheckpoint('query_process_user_input_end')
if (fileHistoryEnabled()) {
queryCheckpoint('query_file_history_snapshot_start')
newMessages.filter(selectableUserMessagesFilter).forEach(message => {
void fileHistoryMakeSnapshot(
(updater: (prev: FileHistoryState) => FileHistoryState) => {
setAppState(prev => ({
...prev,
fileHistory: updater(prev.fileHistory),
}))
},
message.uuid,
)
})
queryCheckpoint('query_file_history_snapshot_end')
}
if (newMessages.length) {
// History is now added in the caller (onSubmit) for direct user submissions.
// This ensures queued command processing (notifications, already-queued user input)
// doesn't add to history, since those either shouldn't be in history or were
// already added when originally queued.
resetHistory()
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
const primaryCmd = commands[0]
const primaryMode = primaryCmd?.mode ?? 'prompt'
const primaryInput =
primaryCmd && typeof primaryCmd.value === 'string'
? primaryCmd.value
: undefined
const shouldCallBeforeQuery = primaryMode === 'prompt'
await onQuery(
newMessages,
abortController,
shouldQuery,
allowedTools ?? [],
model
? resolveSkillModelOverride(model, mainLoopModel)
: mainLoopModel,
shouldCallBeforeQuery ? onBeforeQuery : undefined,
primaryInput,
effort,
)
} else {
// Local slash commands that skip messages (e.g., /model, /theme).
// Release the guard BEFORE clearing toolJSX to prevent spinner flash —
// the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
// If we clear toolJSX while the guard is still reserved, spinner briefly
// shows. The finally below also calls cancelReservation (no-op if idle).
queryGuard.cancelReservation()
setToolJSX({
jsx: null,
shouldHidePromptInput: false,
clearLocalJSX: true,
})
resetHistory()
setAbortController(null)
}
// Handle nextInput from commands that want to chain (e.g., /discover activation)
if (nextInput) {
if (submitNextInput) {
enqueue({ value: nextInput, mode: 'prompt' })
} else {
params.onInputChange(nextInput)
}
}
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
} catch (error) {
turnError = error
}
// Finalize claimed autonomy commands as `completed` only if the turn
// body itself succeeded. Run the finalize call in its own try/catch so a
// failure there does not double-finalize the same commands as `failed`
// (which previously cancelled follow-up queue state after a successful
// turn).
if (claimedAutonomyCommands.length) {
const finalizableCommands = claimedAutonomyCommands.filter(command => {
const runId = command.autonomy?.runId
return !runId || !deferredAutonomyRunIds.has(runId)
})
if (turnError) {
try {
await finalizeAutonomyCommandsForTurn({
commands: finalizableCommands,
outcome: { type: 'failed', error: turnError },
currentDir: getCwd(),
priority: 'later',
workload: turnWorkload,
})
} catch (finalizeError) {
logError(toError(finalizeError))
}
} else {
try {
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: finalizableCommands,
outcome: { type: 'completed' },
currentDir: getCwd(),
priority: 'later',
workload: turnWorkload,
})
for (const nextCommand of nextCommands) {
enqueue(nextCommand)
}
} catch (finalizeError) {
logError(toError(finalizeError))
}
}
}
if (turnError) {
throw turnError
}
} finally {
// Safety net: release the guard reservation if processUserInput threw
// or onQuery was skipped. No-op if onQuery already ran (guard is idle
// via end(), or running — cancelReservation only acts on dispatching).
// This is the single source of truth for releasing the reservation;
// useQueueProcessor no longer needs its own .finally().
queryGuard.cancelReservation()
// Safety net: clear the placeholder if processUserInput produced no
// messages or threw — otherwise it would stay visible until the next
// turn's resetLoadingState. Harmless when onQuery ran: setMessages grew
// displayedMessages past the baseline, so REPL.tsx already hid it.
setUserInputOnProcessing(undefined)
}
}