• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 27708733892

17 Jun 2026 05:50PM UTC coverage: 66.631% (+0.06%) from 66.574%
27708733892

Pull #758

github

web-flow
Merge 9e5296bf0 into 4778a5cd3
Pull Request #758: feat(config, cli, inquire): Configurable Ctrl-C interrupt behavior

110 of 122 new or added lines in 7 files covered. (90.16%)

2 existing lines in 2 files now uncovered.

35567 of 53379 relevant lines covered (66.63%)

403.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

65.49
/crates/jp_cli/src/cmd/query/turn_loop.rs
1
//! Extracted turn loop for testability.
2
//!
3
//! This module contains the core turn loop logic, extracted from `handle_turn`
4
//! to enable integration testing with mock providers.
5

6
use std::{
7
    pin::Pin,
8
    sync::Arc,
9
    task::{Context, Poll},
10
    time::Duration,
11
};
12

13
use camino::Utf8Path;
14
use futures::{
15
    Stream, StreamExt as _, future,
16
    stream::{self, SelectAll},
17
};
18
use indexmap::IndexMap;
19
use jp_attachment::Attachment;
20
use jp_config::{
21
    AppConfig, PartialConfig, assistant::tool_choice::ToolChoice,
22
    conversation::tool::QuestionTarget, model::id::ProviderId, style::streaming::StreamingConfig,
23
};
24
use jp_conversation::{
25
    ConversationStream,
26
    event::{ChatRequest, ToolCallRequest, ToolCallResponse},
27
};
28
use jp_inquire::prompt::PromptBackend;
29
use jp_llm::{
30
    Provider,
31
    error::StreamError,
32
    event::{Event, EventPart, ToolCallPart},
33
    model::ModelDetails,
34
    provider::get_provider,
35
    query::ChatQuery,
36
    tool::{ToolDefinition, executor::Executor},
37
    with_idle_timeout,
38
};
39
use jp_printer::Printer;
40
use jp_workspace::{ConversationLock, ConversationMut};
41
use tokio::{sync::mpsc, task::JoinHandle};
42
use tokio_stream::wrappers::{BroadcastStream, ReceiverStream, errors::BroadcastStreamRecvError};
43
use tokio_util::sync::CancellationToken;
44
use tracing::{debug, info, warn};
45

46
use super::{
47
    build_sections, build_thread,
48
    interrupt::{LoopAction, handle_llm_event, handle_streaming_signal},
49
    stream::{StreamRetryState, handle_stream_error},
50
    tool::{
51
        PendingEntry, PendingTools, ToolCallDecision, ToolCallState, ToolCoordinator, ToolPrompter,
52
        ToolRenderer, build_execution_plan,
53
        inquiry::{InquiryBackend, InquiryConfig, LlmInquiryBackend},
54
        spawn_line_timer,
55
    },
56
    turn::{Action, CommittedEvent, TurnCoordinator, TurnPhase, TurnState},
57
};
58
use crate::{
59
    cmd::query::tool::coordinator::ExecutionResult,
60
    error::Error,
61
    render::metadata::set_rendered_arguments,
62
    signals::{SignalRx, SignalTo},
63
};
64

65
/// Events produced by the merged streaming loop sources.
66
enum StreamingLoopEvent {
67
    /// A signal from the signal handler (e.g.
68
    /// Ctrl+C).
69
    Signal(SignalTo),
70
    /// An event from the LLM provider stream.
71
    Llm(Box<Result<Event, StreamError>>),
72
    /// A tick from the preparing indicator timer, carrying the elapsed time
73
    /// since the timer started.
74
    PreparingTick(Duration),
75
}
76

77
/// Wrapper enum that unifies heterogeneous stream sources for [`SelectAll`].
78
///
79
/// Each variant holds a different concrete stream type, but they all yield
80
/// [`StreamingLoopEvent`].
81
/// This avoids boxing while allowing `select_all` to poll them as a single
82
/// merged stream.
83
enum StreamSource<S, L, T> {
84
    Signal(S),
85
    Llm(L),
86
    Tick(T),
87
}
88

89
impl<S, L, T> Stream for StreamSource<S, L, T>
90
where
91
    S: Stream<Item = StreamingLoopEvent> + Unpin,
92
    L: Stream<Item = StreamingLoopEvent> + Unpin,
93
    T: Stream<Item = StreamingLoopEvent> + Unpin,
94
{
95
    type Item = StreamingLoopEvent;
96

97
    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
331✔
98
        match self.get_mut() {
331✔
99
            Self::Signal(s) => Pin::new(s).poll_next(cx),
63✔
100
            Self::Llm(s) => Pin::new(s).poll_next(cx),
200✔
101
            Self::Tick(s) => Pin::new(s).poll_next(cx),
68✔
102
        }
103
    }
331✔
104
}
105

106
/// Spawns a waiting indicator task that prints elapsed time to the terminal.
107
///
108
/// Returns `None` if the indicator is disabled (not a TTY or config says no).
109
fn spawn_waiting_indicator(
63✔
110
    printer: Arc<Printer>,
63✔
111
    config: &StreamingConfig,
63✔
112
    is_tty: bool,
63✔
113
) -> Option<(CancellationToken, JoinHandle<()>)> {
63✔
114
    if !is_tty {
63✔
115
        return None;
45✔
116
    }
18✔
117

118
    spawn_line_timer(
18✔
119
        printer,
18✔
120
        config.progress.show,
18✔
121
        Duration::from_secs(u64::from(config.progress.delay_secs)),
18✔
122
        Duration::from_millis(u64::from(config.progress.interval_ms)),
18✔
123
        |secs| format!("\r\x1b[K⏱ Waiting… {secs:.1}s"),
5✔
124
    )
125
}
63✔
126

127
/// Runs the turn loop: streaming from LLM, handling signals, executing tools.
128
///
129
/// This is extracted from `handle_turn` to enable integration testing without
130
/// requiring a real LLM provider.
131
/// The function handles the complete turn lifecycle:
132
///
133
/// 1. Streaming LLM responses
134
/// 2. Handling user interrupts (Ctrl+C)
135
/// 3. Executing tool calls
136
/// 4. Persisting conversation state
137
///
138
/// # Errors
139
///
140
/// Returns an error if:
141
///
142
/// - LLM streaming fails with a non-retryable error
143
/// - Tool execution fails critically
144
/// - Workspace persistence fails
145
#[expect(clippy::too_many_lines, clippy::too_many_arguments)]
146
pub(super) async fn run_turn_loop(
37✔
147
    provider: Arc<dyn Provider>,
37✔
148
    model: &ModelDetails,
37✔
149
    cfg: &AppConfig,
37✔
150
    signals: &SignalRx,
37✔
151
    mcp_client: &jp_mcp::Client,
37✔
152
    root: &Utf8Path,
37✔
153
    is_tty: bool,
37✔
154
    attachments: &[Attachment],
37✔
155
    lock: &ConversationLock,
37✔
156
    mut tool_choice: ToolChoice,
37✔
157
    tools: &[ToolDefinition],
37✔
158
    printer: Arc<Printer>,
37✔
159
    prompt_backend: Arc<dyn PromptBackend>,
37✔
160
    mut tool_coordinator: ToolCoordinator,
37✔
161
    chat_request: ChatRequest,
37✔
162
) -> Result<(), Error> {
37✔
163
    let mut turn_state = TurnState::default();
37✔
164
    let mut stream_retry = StreamRetryState::new(cfg.assistant.request, is_tty);
37✔
165
    let idle_timeout = match cfg.assistant.request.stream_idle_timeout_secs {
37✔
166
        0 => None,
×
167
        secs => Some(Duration::from_secs(u64::from(secs))),
37✔
168
    };
169
    let mut turn_coordinator = TurnCoordinator::new(
37✔
170
        printer.clone(),
37✔
171
        cfg.style.clone(),
37✔
172
        cfg.user.name.clone(),
37✔
173
        cfg.assistant.name.clone(),
37✔
174
        Some(cfg.assistant.model.id.resolved().to_string()),
37✔
175
    );
176
    let mut tool_renderer = ToolRenderer::new(
37✔
177
        if cfg.style.tool_call.show && !printer.format().is_json() {
37✔
178
            printer.clone()
37✔
179
        } else {
180
            Printer::sink().into()
×
181
        },
182
        cfg.style.clone(),
37✔
183
        root.to_path_buf(),
37✔
184
        is_tty,
37✔
185
    );
186

187
    let inquiry_backend: Arc<dyn InquiryBackend> = build_inquiry_backend(
37✔
188
        cfg,
37✔
189
        tools.to_vec(),
37✔
190
        model.clone(),
37✔
191
        provider.clone(),
37✔
192
        attachments.to_vec(),
37✔
193
    )
37✔
194
    .await?;
37✔
195

196
    info!(model = model.name(), "Starting conversation turn.");
37✔
197

198
    // Set when an executing phase aborts via user-initiated restart.
199
    // The next executing phase walks the stream for unresponded requests
200
    // and re-prepares them.
201
    let mut restart_requested = false;
37✔
202

203
    // Id-keyed scratchpad for tool work produced during the streaming
204
    // phase. The executing phase derives an ordered execution plan from
205
    // the conversation stream + this scratchpad via `build_execution_plan`.
206
    // Crucially: there's no public way to enumerate this directly — the
207
    // stream is the source of truth for "what needs to run."
208
    let mut pending_tools = PendingTools::new();
37✔
209

210
    // Prompter shared between streaming (permission prompts) and
211
    // executing (tool question prompts) phases.
212
    let prompter = Arc::new(ToolPrompter::with_prompt_backend(
37✔
213
        printer.clone(),
37✔
214
        cfg.editor.path(),
37✔
215
        prompt_backend.clone(),
37✔
216
    ));
217

218
    loop {
219
        match turn_coordinator.current_phase() {
157✔
220
            TurnPhase::Idle => {
221
                lock.as_mut().update_events(|stream| {
37✔
222
                    turn_coordinator.start_turn(stream, chat_request.clone());
37✔
223
                });
37✔
224
            }
225

226
            TurnPhase::Complete | TurnPhase::Aborted => return Ok(()),
35✔
227

228
            TurnPhase::Streaming => {
229
                // Restore structural invariants before each provider request.
230
                // Specifically: any `ToolCallRequest` without a matching
231
                // `ToolCallResponse` gets a synthetic error response. Without
232
                // this, providers like Anthropic reject the request with
233
                // `tool_use ids were found without tool_result blocks`. The
234
                // top-level `query.rs` sanitize covers the first cycle; this
235
                // call covers every subsequent cycle within the turn so a
236
                // mid-turn corruption never reaches the wire.
237
                lock.as_mut().update_events(ConversationStream::sanitize);
63✔
238

239
                // Rebuild thread from workspace events to ensure latest context.
240
                let events_stream = lock.events().clone();
63✔
241

242
                let thread = build_thread(
63✔
243
                    events_stream,
63✔
244
                    attachments.to_vec(),
63✔
245
                    &cfg.assistant,
63✔
246
                    !tools.is_empty(),
63✔
247
                )?;
×
248

249
                let query = ChatQuery {
63✔
250
                    thread,
63✔
251
                    tools: tools.to_vec(),
63✔
252
                    tool_choice: tool_choice.clone(),
63✔
253
                };
63✔
254

255
                // Start waiting indicator BEFORE the HTTP request. The drop
256
                // guard ensures the indicator is cancelled if we exit early
257
                // (error from run_cycle, break, return).
258
                let waiting =
63✔
259
                    spawn_waiting_indicator(printer.clone(), &cfg.style.streaming, is_tty);
63✔
260
                let (waiting_token, mut waiting_handle) = match waiting {
63✔
261
                    Some((token, handle)) => (Some(token), Some(handle)),
17✔
262
                    None => (None, None),
46✔
263
                };
264
                let _waiting_guard = waiting_token
63✔
265
                    .as_ref()
63✔
266
                    .map(CancellationToken::drop_guard_ref);
63✔
267

268
                // Build the three event sources for the streaming loop.
269
                let sig_stream = StreamSource::Signal(
63✔
270
                    BroadcastStream::new(signals.resubscribe()).filter_map(|result| {
63✔
271
                        future::ready(match result {
×
272
                            Ok(signal) => Some(StreamingLoopEvent::Signal(signal)),
×
273
                            Err(BroadcastStreamRecvError::Lagged(n)) => {
×
274
                                warn!("Missed {n} signals due to receiver lag");
×
275
                                None
×
276
                            }
277
                        })
278
                    }),
×
279
                );
280

281
                let raw_stream = provider
63✔
282
                    .chat_completion_stream(model, query)
63✔
283
                    .await
63✔
284
                    .map_err(|e| map_llm_error(e, vec![]))?;
63✔
285
                let raw_stream = match idle_timeout {
63✔
286
                    Some(idle) => with_idle_timeout(raw_stream, idle),
63✔
287
                    None => raw_stream,
×
288
                };
289
                let llm_stream = StreamSource::Llm(
63✔
290
                    raw_stream
63✔
291
                        .fuse()
63✔
292
                        .map(|result| StreamingLoopEvent::Llm(Box::new(result)))
195✔
293
                        // Backstop: if the provider stream ends without a
294
                        // terminal `Finished` event (a dropped or stalled
295
                        // connection), the loop would otherwise pend forever —
296
                        // `SelectAll` only completes once the signal and tick
297
                        // sources are also exhausted, and those never end.
298
                        // Append a synthetic transient error so a premature end
299
                        // routes through the same retry path as any other
300
                        // stream failure. A normal `Finished` breaks the loop
301
                        // before this sentinel is ever polled.
302
                        .chain(stream::once(future::ready(StreamingLoopEvent::Llm(
63✔
303
                            Box::new(Err(StreamError::transient(
63✔
304
                                "provider stream ended without a terminal event",
63✔
305
                            ))),
63✔
306
                        )))),
63✔
307
                );
308
                turn_state.request_count += 1;
63✔
309

310
                // Reset preparing display for this streaming cycle.
311
                tool_renderer.reset();
63✔
312

313
                // Channel for preparing ticks. The sender is passed to
314
                // PreparingDisplay which spawns a timer task. The receiver is
315
                // merged into the event loop via SelectAll.
316
                let (tick_tx, tick_rx) = mpsc::channel::<Duration>(1);
63✔
317
                let tick_stream = StreamSource::Tick(
63✔
318
                    ReceiverStream::new(tick_rx).map(StreamingLoopEvent::PreparingTick),
63✔
319
                );
63✔
320

321
                // Whether we've seen at least one provider event this cycle
322
                // — used to reset the retry budget on the first successful
323
                // event.
324
                let mut received_provider_event = false;
63✔
325

326
                let mut streams: SelectAll<_> =
63✔
327
                    SelectAll::from_iter([sig_stream, llm_stream, tick_stream]);
63✔
328

329
                let mut conv = lock.as_mut();
63✔
330

331
                while let Some(event) = streams.next().await {
203✔
332
                    // Cancel and await the waiting indicator on the first
333
                    // event, ensuring its cleanup (line clear) completes before
334
                    // we render any content.
335
                    if let Some(handle) = waiting_handle.take() {
203✔
336
                        if let Some(token) = &waiting_token {
17✔
337
                            token.cancel();
17✔
338
                        }
17✔
339
                        drop(handle.await);
17✔
340
                    }
186✔
341

342
                    match event {
203✔
343
                        StreamingLoopEvent::Signal(signal) => {
×
344
                            // Clear the preparing display before showing the
345
                            // interrupt menu to avoid visual conflicts.
346
                            tool_renderer.clear_temp_line();
×
347

348
                            let llm_alive =
×
349
                                streams.iter().any(|s| matches!(s, StreamSource::Llm(_)));
×
350

351
                            let action = conv.update_events(|stream| {
×
352
                                handle_streaming_signal(
×
353
                                    signal,
×
354
                                    &mut turn_coordinator,
×
355
                                    stream,
×
356
                                    &printer,
×
357
                                    prompt_backend.as_ref(),
×
NEW
358
                                    &cfg.interrupt.streaming,
×
UNCOV
359
                                    !llm_alive,
×
360
                                )
361
                            });
×
362
                            match action {
×
363
                                LoopAction::Continue => {}
×
364
                                LoopAction::Break => break,
×
365
                                LoopAction::Return(()) => return Ok(()),
×
366
                            }
367
                        }
368

369
                        StreamingLoopEvent::Llm(event) => {
199✔
370
                            let event = *event;
199✔
371

372
                            // Stream errors are handled by the unified retry
373
                            // logic — the single source of truth for retries.
374
                            let event = match event {
199✔
375
                                Ok(event) => event,
193✔
376
                                Err(e) => {
6✔
377
                                    tool_renderer.cancel_all();
6✔
378

379
                                    match handle_stream_error(
6✔
380
                                        e,
6✔
381
                                        &mut stream_retry,
6✔
382
                                        &mut turn_coordinator,
6✔
383
                                        &conv,
6✔
384
                                        &printer,
6✔
385
                                    )
386
                                    .await
6✔
387
                                    {
388
                                        LoopAction::Break => break,
4✔
389
                                        LoopAction::Return(result) => {
2✔
390
                                            // Persist any partial content
391
                                            // flushed before aborting, so a
392
                                            // fatal stream error doesn't
393
                                            // discard streamed work.
394
                                            if let Err(err) = conv.flush() {
2✔
395
                                                warn!("Failed to persist before abort: {err}");
×
396
                                            }
2✔
397
                                            return result;
2✔
398
                                        }
399
                                        LoopAction::Continue => continue,
×
400
                                    }
401
                                }
402
                            };
403

404
                            // Reset the retry counter on the first successful
405
                            // event in this cycle. This ensures that partially
406
                            // successful streams (rate-limited mid-response)
407
                            // don't permanently consume the retry budget.
408
                            if !received_provider_event {
193✔
409
                                received_provider_event = true;
60✔
410
                                stream_retry.clear_line(&printer);
60✔
411
                                stream_retry.reset();
60✔
412
                            }
133✔
413

414
                            // Register preparing tool calls. Flush the markdown
415
                            // buffer first so buffered text appears before the
416
                            // "Calling tool" line (fixes Issue 1).
417
                            if let Event::Part {
418
                                part: EventPart::ToolCall(ToolCallPart::Start { id, name }),
29✔
419
                                ..
420
                            } = &event
32✔
421
                            {
29✔
422
                                turn_coordinator.flush_renderer();
29✔
423
                                turn_coordinator.transition_to_tool_call();
29✔
424

29✔
425
                                tool_renderer.register(id, name, &tick_tx);
29✔
426
                                tool_coordinator
29✔
427
                                    .set_tool_state(id, ToolCallState::ReceivingArguments {
29✔
428
                                        name: name.clone(),
29✔
429
                                    });
29✔
430
                            }
164✔
431

432
                            let is_finished = matches!(event, Event::Finished(_));
193✔
433

434
                            // `handle_llm_event` returns turn control plus
435
                            // any newly committed event that needs immediate
436
                            // shell handling. We dispatch on the committed
437
                            // event, not on the stream tail: a duplicate flush
438
                            // from a misbehaving provider commits nothing and
439
                            // so cannot drive a double dispatch.
440
                            let (action, committed) = conv.update_events(|stream| {
193✔
441
                                handle_llm_event(event, &mut turn_coordinator, stream)
193✔
442
                            });
193✔
443
                            match action {
193✔
444
                                LoopAction::Continue => {}
136✔
445
                                LoopAction::Break => break,
57✔
446
                                LoopAction::Return(()) => return Ok(()),
×
447
                            }
448

449
                            // On a flushed tool-call request: clear the temp
450
                            // line, prepare the executor, decide permission,
451
                            // then render the tool call header + arguments.
452
                            // For attended tools the permission prompt comes
453
                            // first, so the user approves before seeing the
454
                            // full rendering.
455
                            if let CommittedEvent::ToolCallRequest(req) = committed {
136✔
456
                                tool_coordinator.set_tool_state(&req.id, ToolCallState::Queued);
28✔
457
                                tool_renderer.complete(&req.id);
28✔
458

459
                                match tool_coordinator.prepare_one(req.clone()) {
28✔
460
                                    Ok(executor) => {
18✔
461
                                        // Run the unified per-tool permission
462
                                        // pipeline. The await blocks the
463
                                        // streaming event loop while the user
464
                                        // decides; LLM events buffer in the
465
                                        // channel and are processed after.
466
                                        let decision = tool_coordinator
18✔
467
                                            .resolve_tool_call_decision(
18✔
468
                                                executor,
18✔
469
                                                &prompter,
18✔
470
                                                is_tty,
18✔
471
                                                &mut turn_state,
18✔
472
                                                &tool_renderer,
18✔
473
                                            )
18✔
474
                                            .await;
18✔
475

476
                                        match decision {
18✔
477
                                            ToolCallDecision::Approved {
478
                                                executor,
16✔
479
                                                rendered_arguments,
16✔
480
                                            } => {
481
                                                if let Some(content) = rendered_arguments {
16✔
482
                                                    conv.update_events(|stream| {
×
483
                                                        store_rendered_arguments(
×
484
                                                            stream, &req.id, &content,
×
485
                                                        );
486
                                                    });
×
487
                                                }
16✔
488
                                                pending_tools
16✔
489
                                                    .insert_approved(req.id.clone(), executor);
16✔
490
                                            }
491
                                            ToolCallDecision::Skipped(resp)
2✔
492
                                            | ToolCallDecision::Failed(resp) => {
2✔
493
                                                pending_tools.insert_resolved(req.id.clone(), resp);
2✔
494
                                            }
2✔
495
                                        }
496
                                    }
497
                                    Err(resp) => {
10✔
498
                                        pending_tools.insert_resolved(req.id.clone(), resp);
10✔
499
                                    }
10✔
500
                                }
501
                            }
108✔
502

503
                            if is_finished {
136✔
504
                                tool_renderer.cancel_all();
×
505
                            }
136✔
506
                        }
507

508
                        StreamingLoopEvent::PreparingTick(elapsed) => {
4✔
509
                            tool_renderer.tick(elapsed);
4✔
510
                        }
4✔
511
                    }
512
                }
513

514
                // Clean up any preparing state on early loop exit.
515
                tool_renderer.cancel_all();
61✔
516

517
                conv.flush()?;
61✔
518
            }
519

520
            TurnPhase::Executing => {
521
                // On restart: walk the stream for unresponded tool-call
522
                // requests in the current turn and re-prepare them into
523
                // `pending_tools` via the existing batch APIs. From there
524
                // the unified executing path below picks up.
525
                //
526
                // The streaming and restart prep flows are still two
527
                // separate codepaths today; both converge on
528
                // `pending_tools` and `build_execution_plan`, which is the
529
                // load-bearing invariant for this refactor.
530
                if restart_requested {
22✔
531
                    restart_requested = false;
×
532

533
                    let restart_calls: Vec<ToolCallRequest> = lock
×
534
                        .events()
×
535
                        .iter_turns()
×
536
                        .next_back()
×
537
                        .map(|t| {
×
538
                            t.iter()
×
539
                                .filter_map(|e| e.event.as_tool_call_request())
×
540
                                .filter(|req| {
×
541
                                    lock.events().find_tool_call_response(&req.id).is_none()
×
542
                                })
×
543
                                .cloned()
×
544
                                .collect()
×
545
                        })
×
546
                        .unwrap_or_default();
×
547

548
                    if restart_calls.is_empty() {
×
549
                        break;
×
550
                    }
×
551

552
                    let unavailable = tool_coordinator.prepare(restart_calls);
×
553
                    let restart_prompter = ToolPrompter::with_prompt_backend(
×
554
                        printer.clone(),
×
555
                        cfg.editor.path(),
×
556
                        prompt_backend.clone(),
×
557
                    );
558
                    let (executors, skipped) = tool_coordinator
×
559
                        .run_permission_phase(
×
560
                            &restart_prompter,
×
561
                            is_tty,
×
562
                            &mut turn_state,
×
563
                            &tool_renderer,
×
564
                        )
565
                        .await;
×
566

567
                    for (_idx, exec) in executors {
×
568
                        let id = exec.tool_id().to_owned();
×
569
                        pending_tools.insert_approved(id, exec);
×
570
                    }
×
571
                    for (_idx, resp) in skipped {
×
572
                        pending_tools.insert_resolved(resp.id.clone(), resp);
×
573
                    }
×
574
                    for (_idx, resp) in unavailable {
×
575
                        pending_tools.insert_resolved(resp.id.clone(), resp);
×
576
                    }
×
577
                }
22✔
578

579
                // Unified executing path: derive the work to do by walking
580
                // the conversation stream and reconciling against
581
                // `pending_tools`. The stream is the source of truth.
582
                let mut conv = lock.as_mut();
22✔
583
                let plan = build_execution_plan(&conv.events(), &mut pending_tools);
22✔
584

585
                if plan.is_empty() {
22✔
586
                    break;
×
587
                }
22✔
588

589
                let (items, orphaned) = plan.into_parts();
22✔
590

591
                let mut approved: Vec<(usize, Box<dyn Executor>)> = Vec::new();
22✔
592
                let mut pre_resolved: Vec<(usize, ToolCallResponse)> = Vec::new();
22✔
593
                for item in items {
28✔
594
                    match item.work {
28✔
595
                        PendingEntry::Approved(exec) => approved.push((item.index, exec)),
16✔
596
                        PendingEntry::Resolved(resp) => pre_resolved.push((item.index, resp)),
12✔
597
                    }
598
                }
599

600
                // Orphans: a `ToolCallRequest` in the stream's current turn
601
                // without a matching pending entry. Should never happen in
602
                // correct operation — every flushed request goes through
603
                // the prep flow which writes to `pending_tools`. Synthesize
604
                // an error response so the conversation stays valid (every
605
                // request must have a response before the next provider
606
                // call) and surface the inconsistency.
607
                for (idx, req) in orphaned {
22✔
608
                    warn!(
×
609
                        id = %req.id,
610
                        name = %req.name,
611
                        "ToolCallRequest in stream without a pending entry; synthesizing error \
612
                         response.",
613
                    );
614
                    pre_resolved.push((idx, ToolCallResponse {
×
615
                        id: req.id,
×
616
                        result: Err(
×
617
                            "Tool call had no prepared executor (internal inconsistency).".into(),
×
618
                        ),
×
619
                    }));
×
620
                }
621

622
                tool_coordinator.reset_for_execution();
22✔
623

624
                let execution_result = tool_coordinator
22✔
625
                    .execute_with_prompting(
22✔
626
                        approved,
22✔
627
                        Arc::clone(&prompter),
22✔
628
                        signals.resubscribe(),
22✔
629
                        &mut turn_coordinator,
22✔
630
                        &mut turn_state,
22✔
631
                        &printer,
22✔
632
                        prompt_backend.as_ref(),
22✔
633
                        Arc::clone(&inquiry_backend),
22✔
634
                        &conv,
22✔
635
                        mcp_client,
22✔
636
                        root,
22✔
637
                        &tool_renderer,
22✔
638
                        is_tty,
22✔
639
                    )
22✔
640
                    .await;
22✔
641

642
                if execution_result.restart_requested {
22✔
643
                    restart_requested = true;
×
644
                    continue;
×
645
                }
22✔
646

647
                if commit_tool_responses(
22✔
648
                    execution_result,
22✔
649
                    pre_resolved,
22✔
650
                    &mut tool_coordinator,
22✔
651
                    &mut turn_coordinator,
22✔
652
                    &mut conv,
22✔
653
                )? {
22✔
654
                    tool_choice = ToolChoice::Auto;
22✔
655
                }
22✔
656
            }
657
        }
658
    }
659

660
    Ok(())
×
661
}
37✔
662

663
async fn build_inquiry_backend(
37✔
664
    cfg: &AppConfig,
37✔
665
    tools: Vec<ToolDefinition>,
37✔
666
    model: ModelDetails,
37✔
667
    provider: Arc<dyn Provider>,
37✔
668
    attachments: Vec<Attachment>,
37✔
669
) -> Result<Arc<LlmInquiryBackend>, Error> {
37✔
670
    let sections = build_sections(&cfg.assistant, !tools.is_empty());
37✔
671
    let inquiry_override = &cfg.conversation.inquiry.assistant;
37✔
672

673
    // Use the inquiry system prompt if configured, otherwise fall back to the
674
    // parent assistant's system prompt.
675
    let default_system_prompt = inquiry_override
37✔
676
        .system_prompt
37✔
677
        .clone()
37✔
678
        .or_else(|| cfg.assistant.system_prompt.clone());
37✔
679

680
    // Track providers we've already constructed to avoid duplicates.
681
    let mut providers: IndexMap<ProviderId, Arc<dyn Provider>> = IndexMap::new();
37✔
682

683
    // Build the default InquiryConfig from the global inquiry override
684
    // merged with the parent assistant config.
685
    let default_config = if let Some(inquiry_model_cfg) = inquiry_override.model.as_ref() {
37✔
686
        let inquiry_model_id = inquiry_model_cfg.id.resolved();
×
687
        let inquiry_provider: Arc<dyn Provider> =
×
688
            Arc::from(get_provider(inquiry_model_id.provider, &cfg.providers.llm)?);
×
689
        debug!(model = %inquiry_model_id, "Fetching inquiry model details.");
×
690
        let inquiry_model = inquiry_provider
×
691
            .model_details(&inquiry_model_id.name)
×
692
            .await?;
×
693

694
        if inquiry_model.structured_output == Some(false) {
×
695
            warn!(
×
696
                model = inquiry_model_id.to_string(),
×
697
                "Inquiry model does not support structured output. Inquiry responses may be \
698
                 unreliable.",
699
            );
700
        }
×
701

702
        info!(
×
703
            model = inquiry_model.name(),
×
704
            "Using dedicated model for inquiries."
705
        );
706

707
        providers.insert(inquiry_model_id.provider, Arc::clone(&inquiry_provider));
×
708

709
        InquiryConfig {
×
710
            provider: inquiry_provider,
×
711
            model: inquiry_model,
×
712
            system_prompt: default_system_prompt,
×
713
            sections: sections.clone(),
×
714
        }
×
715
    } else {
716
        providers.insert(model.id.provider, Arc::clone(&provider));
37✔
717

718
        InquiryConfig {
37✔
719
            provider: Arc::clone(&provider),
37✔
720
            model: model.clone(),
37✔
721
            system_prompt: default_system_prompt,
37✔
722
            sections: sections.clone(),
37✔
723
        }
37✔
724
    };
725

726
    let overrides = build_inquiry_overrides(cfg, &default_config, &mut providers).await?;
37✔
727

728
    Ok(Arc::new(LlmInquiryBackend::new(
37✔
729
        default_config,
37✔
730
        overrides,
37✔
731
        attachments,
37✔
732
        tools,
37✔
733
    )))
37✔
734
}
37✔
735

736
/// Walk active tool configs to build per-question [`InquiryConfig`] overrides
737
/// from `QuestionTarget::Assistant(config)` entries that have non-empty config.
738
async fn build_inquiry_overrides(
37✔
739
    cfg: &AppConfig,
37✔
740
    default_config: &InquiryConfig,
37✔
741
    providers: &mut IndexMap<ProviderId, Arc<dyn Provider>>,
37✔
742
) -> Result<IndexMap<(String, String), InquiryConfig>, Error> {
37✔
743
    let mut overrides = IndexMap::new();
37✔
744

745
    for (tool_name, tool_cfg) in cfg.conversation.tools.iter() {
37✔
746
        for (question_id, question_cfg) in tool_cfg.questions() {
22✔
747
            let QuestionTarget::Assistant(ref per_q) = question_cfg.target else {
7✔
748
                continue;
×
749
            };
750
            if PartialConfig::is_empty(per_q.as_ref()) {
7✔
751
                continue;
7✔
752
            }
×
753

754
            // Resolve per-question model (if overridden), falling back to
755
            // the default inquiry config's model.
756
            let has_model_override = !PartialConfig::is_empty(&per_q.model.id);
×
757
            let (inq_provider, inq_model) = if has_model_override {
×
758
                let model_id = per_q
×
759
                    .model
×
760
                    .id
×
761
                    .resolve(&cfg.providers.llm.aliases)
×
762
                    .map_err(|e| Error::CliConfig(e.to_string()))?;
×
763

764
                let prov = if let Some(p) = providers.get(&model_id.provider) {
×
765
                    Arc::clone(p)
×
766
                } else {
767
                    let p: Arc<dyn Provider> =
×
768
                        Arc::from(get_provider(model_id.provider, &cfg.providers.llm)?);
×
769
                    providers.insert(model_id.provider, Arc::clone(&p));
×
770
                    p
×
771
                };
772

773
                let details = prov.model_details(&model_id.name).await?;
×
774

775
                if details.structured_output == Some(false) {
×
776
                    warn!(
×
777
                        tool = %tool_name,
778
                        question = %question_id,
779
                        model = %model_id,
780
                        "Per-question inquiry model does not support structured \
781
                         output. Inquiry responses may be unreliable.",
782
                    );
783
                }
×
784

785
                (prov, details)
×
786
            } else {
787
                (
×
788
                    Arc::clone(&default_config.provider),
×
789
                    default_config.model.clone(),
×
790
                )
×
791
            };
792

793
            // System prompt: per-question -> global inquiry -> main.
794
            let system_prompt = per_q
×
795
                .system_prompt
×
796
                .as_ref()
×
797
                .map(|s| s.to_string())
×
798
                .or_else(|| default_config.system_prompt.clone());
×
799

800
            overrides.insert((tool_name.to_owned(), question_id.clone()), InquiryConfig {
×
801
                provider: inq_provider,
×
802
                model: inq_model,
×
803
                system_prompt,
×
804
                sections: default_config.sections.clone(),
×
805
            });
×
806
        }
807
    }
808

809
    Ok(overrides)
37✔
810
}
37✔
811

812
/// Assemble tool responses from the executor's results plus any pre-resolved
813
/// responses (skipped tools, unavailable tools, orphan synthesizations), commit
814
/// them to the conversation stream, and flush to disk.
815
///
816
/// Returns `true` if a follow-up LLM cycle is needed (i.e. tool responses were
817
/// added and the coordinator wants to continue).
818
fn commit_tool_responses(
22✔
819
    result: ExecutionResult,
22✔
820
    pre_resolved: Vec<(usize, ToolCallResponse)>,
22✔
821
    tool: &mut ToolCoordinator,
22✔
822
    turn: &mut super::turn::TurnCoordinator,
22✔
823
    conv: &mut ConversationMut,
22✔
824
) -> Result<bool, Error> {
22✔
825
    // Persist any rendered custom-argument output accumulated during the
826
    // permission phase into the corresponding ToolCallRequest events.
827
    flush_rendered_arguments(tool, conv);
22✔
828

829
    // Both `result.responses` and `pre_resolved` are already keyed by the
830
    // plan index assigned in `build_execution_plan`. Sorting by that
831
    // index restores stream order for the persisted responses.
832
    let mut indexed: Vec<(usize, ToolCallResponse)> = result.responses;
22✔
833
    indexed.extend(pre_resolved);
22✔
834
    indexed.sort_by_key(|(idx, _)| *idx);
22✔
835
    let responses: Vec<_> = indexed.into_iter().map(|(_, r)| r).collect();
22✔
836

837
    let action = conv.update_events(|stream| turn.handle_tool_responses(stream, responses));
22✔
838
    conv.flush()?;
22✔
839

840
    Ok(matches!(action, Action::SendFollowUp))
22✔
841
}
22✔
842

843
/// Write a single rendered-arguments entry into event metadata.
844
fn store_rendered_arguments(stream: &mut ConversationStream, tool_call_id: &str, content: &str) {
×
845
    for event in stream.iter_mut() {
×
846
        let is_match = event
×
847
            .event
×
848
            .as_tool_call_request()
×
849
            .is_some_and(|r| r.id == tool_call_id);
×
850
        if is_match {
×
851
            set_rendered_arguments(event.event, content);
×
852
            return;
×
853
        }
×
854
    }
855
}
×
856

857
/// Drain accumulated rendered arguments from the coordinator and write them
858
/// into the corresponding `ToolCallRequest` events in the stream.
859
fn flush_rendered_arguments(coordinator: &mut ToolCoordinator, conv: &mut ConversationMut) {
22✔
860
    let rendered = coordinator.drain_rendered_arguments();
22✔
861
    if rendered.is_empty() {
22✔
862
        return;
22✔
863
    }
×
864
    conv.update_events(|stream| {
×
865
        for (tool_call_id, content) in &rendered {
×
866
            store_rendered_arguments(stream, tool_call_id, content);
×
867
        }
×
868
    });
×
869
}
22✔
870

871
fn map_llm_error(error: jp_llm::Error, models: Vec<ModelDetails>) -> Error {
×
872
    match error {
×
873
        jp_llm::Error::UnknownModel(model) => Error::UnknownModel {
×
874
            model,
×
875
            available: models.into_iter().map(|v| v.id.name.to_string()).collect(),
×
876
        },
877
        _ => error.into(),
×
878
    }
879
}
×
880

881
#[cfg(test)]
882
#[path = "turn_loop_tests.rs"]
883
mod tests;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc