( opts: AskSideQuestionOptions )
| 164 | } |
| 165 | |
| 166 | export async function askSideQuestion( |
| 167 | opts: AskSideQuestionOptions |
| 168 | ): Promise<Result<AskSideQuestionSuccess, NameGenerationError>> { |
| 169 | const { workspaceId, question, candidates, aiService, historyService, emitChatEvent } = opts; |
| 170 | |
| 171 | const trimmedQuestion = question.trim(); |
| 172 | if (trimmedQuestion.length === 0) { |
| 173 | return Err({ type: "unknown", raw: "Side question is empty" }); |
| 174 | } |
| 175 | |
| 176 | if (candidates.length === 0) { |
| 177 | return Err({ type: "unknown", raw: "No model candidates available for side question" }); |
| 178 | } |
| 179 | |
| 180 | // --------------------------------------------------------------------- |
| 181 | // 0. Snapshot any in-flight MAIN-AGENT stream so the renderer can later |
| 182 | // split the interrupted message into pre-aside + post-aside halves |
| 183 | // around this /btw pair. |
| 184 | // |
| 185 | // Two structural guarantees make this safe to read synchronously: |
| 186 | // - `aiService.getStreamInfo` is a sync getter over an in-memory map |
| 187 | // (StreamManager.workspaceStreams), so no race with disk I/O. |
| 188 | // - The /btw pipeline bypasses StreamManager entirely (no |
| 189 | // `streamManager.startStream` call), so `getStreamInfo` can ONLY |
| 190 | // return a main-agent stream — never a concurrent /btw. The |
| 191 | // "side question must not interrupt itself" filter is therefore |
| 192 | // structural rather than a runtime check. |
| 193 | // |
| 194 | // MUST run before the first `await` below: any awaited work between |
| 195 | // this read and the user-message append widens the racy window where |
| 196 | // the main agent could finish streaming (StreamingMessageAggregator |
| 197 | // would then no longer have the interruption anchor we promise). |
| 198 | // --------------------------------------------------------------------- |
| 199 | const liveStreamSnapshot = |
| 200 | opts.liveStreamSnapshot ?? snapshotSideQuestionLiveStream(aiService.getStreamInfo(workspaceId)); |
| 201 | const interruption = liveStreamSnapshot |
| 202 | ? { |
| 203 | interruptedMessageId: liveStreamSnapshot.messageId, |
| 204 | // Text length anchors the split across text parts; part index keeps |
| 205 | // non-text parts (reasoning/tool/file) that were already visible at |
| 206 | // the same text offset on the pre-aside side after reload. |
| 207 | interruptedTextLength: liveStreamSnapshot.parts.reduce( |
| 208 | (sum, p) => (p.type === "text" ? sum + p.text.length : sum), |
| 209 | 0 |
| 210 | ), |
| 211 | interruptedPartIndex: liveStreamSnapshot.parts.length, |
| 212 | interruptedHistorySequence: liveStreamSnapshot.historySequence, |
| 213 | } |
| 214 | : undefined; |
| 215 | |
| 216 | // --------------------------------------------------------------------- |
| 217 | // 1. Persist + emit the user's /btw message FIRST so the question shows |
| 218 | // up in the chat immediately. Even if the model call fails, the user |
| 219 | // can see what they asked. |
| 220 | // --------------------------------------------------------------------- |
| 221 | const rawCommand = `${SIDE_QUESTION_COMMAND} ${trimmedQuestion}`; |
| 222 | const userMessage: MuxMessage = createMuxMessage(createUserMessageId(), "user", trimmedQuestion, { |
| 223 | timestamp: Date.now(), |
no test coverage detected