/ utils / queueProcessor.ts
queueProcessor.ts
 1  import type { QueuedCommand } from '../types/textInputTypes.js'
 2  import {
 3    dequeue,
 4    dequeueAllMatching,
 5    hasCommandsInQueue,
 6    peek,
 7  } from './messageQueueManager.js'
 8  
 9  type ProcessQueueParams = {
10    executeInput: (commands: QueuedCommand[]) => Promise<void>
11  }
12  
13  type ProcessQueueResult = {
14    processed: boolean
15  }
16  
17  /**
18   * Check if a queued command is a slash command (value starts with '/').
19   */
20  function isSlashCommand(cmd: QueuedCommand): boolean {
21    if (typeof cmd.value === 'string') {
22      return cmd.value.trim().startsWith('/')
23    }
24    // For ContentBlockParam[], check the first text block
25    for (const block of cmd.value) {
26      if (block.type === 'text') {
27        return block.text.trim().startsWith('/')
28      }
29    }
30    return false
31  }
32  
33  /**
34   * Processes commands from the queue.
35   *
36   * Slash commands (starting with '/') and bash-mode commands are processed
37   * one at a time so each goes through the executeInput path individually.
38   * Bash commands need individual processing to preserve per-command error
39   * isolation, exit codes, and progress UI. Other non-slash commands are
40   * batched: all items **with the same mode** as the highest-priority item
41   * are drained at once and passed as a single array to executeInput — each
42   * becomes its own user message with its own UUID. Different modes
43   * (e.g. prompt vs task-notification) are never mixed because they are
44   * treated differently downstream.
45   *
46   * The caller is responsible for ensuring no query is currently running
47   * and for calling this function again after each command completes
48   * until the queue is empty.
49   *
50   * @returns result with processed status
51   */
52  export function processQueueIfReady({
53    executeInput,
54  }: ProcessQueueParams): ProcessQueueResult {
55    // This processor runs on the REPL main thread between turns. Skip anything
56    // addressed to a subagent — an unfiltered peek() returning a subagent
57    // notification would set targetMode, dequeueAllMatching would find nothing
58    // matching that mode with agentId===undefined, and we'd return processed:
59    // false with the queue unchanged → the React effect never re-fires and any
60    // queued user prompt stalls permanently.
61    const isMainThread = (cmd: QueuedCommand) => cmd.agentId === undefined
62  
63    const next = peek(isMainThread)
64    if (!next) {
65      return { processed: false }
66    }
67  
68    // Slash commands and bash-mode commands are processed individually.
69    // Bash commands need per-command error isolation, exit codes, and progress UI.
70    if (isSlashCommand(next) || next.mode === 'bash') {
71      const cmd = dequeue(isMainThread)!
72      void executeInput([cmd])
73      return { processed: true }
74    }
75  
76    // Drain all non-slash-command items with the same mode at once.
77    const targetMode = next.mode
78    const commands = dequeueAllMatching(
79      cmd => isMainThread(cmd) && !isSlashCommand(cmd) && cmd.mode === targetMode,
80    )
81    if (commands.length === 0) {
82      return { processed: false }
83    }
84  
85    void executeInput(commands)
86    return { processed: true }
87  }
88  
89  /**
90   * Checks if the queue has pending commands.
91   * Use this to determine if queue processing should be triggered.
92   */
93  export function hasQueuedCommands(): boolean {
94    return hasCommandsInQueue()
95  }