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

dcdpr / jp / 25391169006

05 May 2026 05:15PM UTC coverage: 64.354% (-0.08%) from 64.43%
25391169006

Pull #598

github

web-flow
Merge 8e37e2827 into 76d733523
Pull Request #598: feat(config, conversation, cli): Add `user.name` attribution to turns

28 of 89 new or added lines in 8 files covered. (31.46%)

3 existing lines in 3 files now uncovered.

24641 of 38290 relevant lines covered (64.35%)

181.82 hits per line

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

29.04
/crates/jp_cli/src/cmd/query.rs
1
//! Query command implementation using the stream pipeline architecture.
2
//!
3
//! # Architecture Overview
4
//!
5
//! The query command handles conversational interactions with LLMs. It uses a
6
//! component-based architecture with clear separation of concerns.
7
//!
8
//! # Key Components
9
//!
10
//! - [`TurnCoordinator`]: State machine managing the turn lifecycle (Idle →
11
//!   Streaming → Executing → Complete/Aborted).
12
//!
13
//! - [`EventBuilder`]: Accumulates streamed chunks by index and produces
14
//!   complete [`ConversationEvent`]s on flush.
15
//!
16
//! - [`ChatRenderer`]: Renders LLM output (reasoning and messages) to
17
//!   the terminal with display mode support.
18
//!
19
//! - [`StreamRetryState`]: Single source of
20
//!   truth for stream retry logic (backoff, notification, state flushing).
21
//!
22
//! - [`ToolCoordinator`]: Manages parallel tool execution.
23
//!
24
//! - [`InterruptHandler`]: Handles Ctrl+C with context-aware menus (streaming
25
//!   vs tool execution).
26
//!
27
//! # Turn Lifecycle
28
//!
29
//! A "turn" is the complete interaction from user query to final response:
30
//!
31
//! 1. User sends a query ([`ChatRequest`]).
32
//! 2. LLM streams response chunks ([`ChatResponse`]).
33
//! 3. If [`ToolCallRequest`] present: execute tools, send [`ToolCallResponse`],
34
//!    goto 2 (new cycle, same turn).
35
//! 4. If no tool calls: turn complete, persist and exit.
36
//!
37
//! See `docs/architecture/query-stream-pipeline.md` for the full design
38
//! document.
39
//!
40
//! [`TurnCoordinator`]: turn::coordinator::TurnCoordinator
41
//! [`EventBuilder`]: jp_llm::event_builder::EventBuilder
42
//! [`ConversationEvent`]: jp_conversation::event::ConversationEvent
43
//! [`ChatRenderer`]: crate::render::ChatRenderer
44
//! [`StreamRetryState`]: stream::retry::StreamRetryState
45
//! [`InterruptHandler`]: interrupt::handler::InterruptHandler
46
//! [`ToolCallRequest`]: jp_conversation::event::ToolCallRequest
47
//! [`ToolCallResponse`]: jp_conversation::event::ToolCallResponse
48

49
mod interrupt;
50
mod stream;
51
pub(crate) mod tool;
52
mod turn;
53
mod turn_loop;
54

55
use std::{
56
    collections::HashSet,
57
    env, fs,
58
    io::{self, BufRead as _, IsTerminal},
59
    sync::Arc,
60
    time::Duration,
61
};
62

63
use camino::{Utf8Path, Utf8PathBuf};
64
use clap::{ArgAction, builder::TypedValueParser as _};
65
use indexmap::IndexMap;
66
use jp_attachment::Attachment;
67
use jp_config::{
68
    AppConfig, PartialAppConfig, PartialConfig as _,
69
    assignment::{AssignKeyValue as _, KvAssignment},
70
    assistant::{
71
        AssistantConfig, instructions::InstructionsConfig, sections::SectionConfig,
72
        tool_choice::ToolChoice,
73
    },
74
    conversation::{ConversationConfig, tool::Enable},
75
    fs::{expand_tilde, load_partial},
76
    model::parameters::{PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig},
77
    style::reasoning::ReasoningDisplayConfig,
78
};
79
use jp_conversation::{
80
    Conversation, ConversationEvent, ConversationId, ConversationStream,
81
    event::{ChatRequest, ChatResponse},
82
    thread::{Thread, ThreadBuilder},
83
};
84
use jp_inquire::prompt::TerminalPromptBackend;
85
use jp_llm::{
86
    ToolError, provider,
87
    tool::{
88
        ToolDefinition, ToolDocs,
89
        builtin::{BuiltinExecutors, describe_tools::DescribeTools},
90
        tool_definitions,
91
    },
92
};
93
use jp_md::format::Formatter;
94
use jp_printer::Printer;
95
use jp_task::task::TitleGeneratorTask;
96
use jp_workspace::{ConversationHandle, ConversationLock, Workspace};
97
use minijinja::{Environment, UndefinedBehavior};
98
use tool::{TerminalExecutorSource, ToolCoordinator};
99
use tracing::{debug, trace, warn};
100
use turn_loop::run_turn_loop;
101

102
use super::{
103
    ConversationLoadRequest, Output, attachment::register_attachment, conversation_id::FlagIds,
104
    lock::LockOutcome,
105
};
106
use crate::{
107
    Ctx, PATH_STRING_PREFIX,
108
    cmd::{
109
        self,
110
        conversation::fork,
111
        lock::{LockRequest, acquire_lock},
112
    },
113
    ctx::IntoPartialAppConfig,
114
    editor::{self, Editor},
115
    error::{Error, Result},
116
    output::print_json,
117
    parser::AttachmentUrlOrPath,
118
    signals::SignalRx,
119
};
120

121
type BoxedResult<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
122

123
#[derive(Debug, Default, clap::Args)]
124
pub(crate) struct Query {
125
    /// The query to send. If not provided, uses `$JP_EDITOR`, `$VISUAL` or
126
    /// `$EDITOR` to open edit the query in an editor.
127
    #[arg(value_parser = string_or_path)]
128
    query: Option<Vec<String>>,
129

130
    /// Use the query string as a Jinja2 template.
131
    ///
132
    /// You can provide values for template variables using the
133
    /// `template.values` config key.
134
    #[arg(short = '%', long)]
135
    template: bool,
136

137
    /// Constrain the assistant's response to match a JSON schema.
138
    ///
139
    /// Accepts either a full JSON Schema object or a concise DSL:
140
    ///
141
    ///   -s 'summary'                   → single string field
142
    ///   -s 'name, age int, bio'        → mixed types
143
    ///   -s 'summary: a brief summary'  → field with description
144
    ///
145
    /// See: <https://jp.computer/rfd/030-schema-dsl>
146
    #[arg(short = 's', long, value_parser = string_or_path.try_map(parse_schema))]
147
    schema: Option<schemars::Schema>,
148

149
    /// Replay the last message in the conversation.
150
    ///
151
    /// If a query is provided, it will be appended to the end of the previous
152
    /// message. If no query is provided, $EDITOR will open with the last
153
    /// message in the conversation.
154
    #[arg(long = "replay", conflicts_with = "new")]
155
    replay: bool,
156

157
    #[command(flatten)]
158
    target: FlagIds<false, false>,
159

160
    /// Fork the session's active conversation (or the one specified by --id)
161
    /// and start a new turn on the fork.
162
    ///
163
    /// If N is given, the fork keeps only the last N turns.
164
    #[arg(
165
        long = "fork",
166
        num_args = 0..=1,
167
        default_missing_value = "",
168
        value_parser = parse_fork_turns,
169
        conflicts_with = "new",
170
    )]
171
    fork: Option<Option<usize>>,
172

173
    /// Start a new conversation without any message history.
174
    #[arg(short = 'n', long = "new", group = "new", conflicts_with = "id")]
175
    new_conversation: bool,
176

177
    /// Store the conversation locally, outside of the workspace.
178
    #[arg(
179
        short = 'l',
180
        long = "local",
181
        requires = "new_conversation",
182
        conflicts_with = "no_local"
183
    )]
184
    local: bool,
185

186
    /// Store the conversation in the current workspace.
187
    #[arg(
188
        short = 'L',
189
        long = "no-local",
190
        requires = "new_conversation",
191
        conflicts_with = "local"
192
    )]
193
    no_local: bool,
194

195
    /// Add attachment to the configuration.
196
    #[arg(short = 'a', long = "attachment", alias = "attach")]
197
    attachments: Vec<AttachmentUrlOrPath>,
198

199
    /// Whether and how to edit the query.
200
    ///
201
    /// Setting this flag to `true`, omitting it, or using it as a boolean flag
202
    /// (e.g. `--edit`) will use the default editor configured elsewhere, or
203
    /// return an error if no editor is configured and one is required.
204
    ///
205
    /// If set to `false`, the editor will be disabled (similar to `--no-edit`),
206
    /// which might result in an error if the editor is required.
207
    ///
208
    /// If set to any other value, it will be used as the command to open the
209
    /// editor.
210
    #[arg(short = 'e', long = "edit", conflicts_with = "no_edit")]
211
    edit: Option<Option<Editor>>,
212

213
    /// Do not edit the query.
214
    ///
215
    /// See `--edit` for more details.
216
    #[arg(short = 'E', long = "no-edit", conflicts_with = "edit")]
217
    no_edit: bool,
218

219
    /// The model to use.
220
    #[arg(short = 'm', long = "model")]
221
    model: Option<String>,
222

223
    /// The model parameters to use.
224
    #[arg(short = 'p', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append)]
225
    parameters: Vec<KvAssignment>,
226

227
    /// Enable reasoning.
228
    #[arg(short = 'r', long = "reasoning")]
229
    reasoning: Option<ReasoningConfig>,
230

231
    /// Disable reasoning.
232
    #[arg(short = 'R', long = "no-reasoning")]
233
    no_reasoning: bool,
234

235
    /// Do not display the reasoning content.
236
    ///
237
    /// This does not stop the assistant from generating reasoning tokens to
238
    /// help with its accuracy, but it does not display them in the output.
239
    #[arg(long = "hide-reasoning")]
240
    hide_reasoning: bool,
241

242
    /// Do not display tool calls.
243
    ///
244
    /// This does not stop the assistant from running tool calls, but it does
245
    /// not display them in the output.
246
    #[arg(long = "hide-tool-calls")]
247
    hide_tool_calls: bool,
248

249
    #[command(flatten)]
250
    tool_directives: ToolDirectives,
251

252
    /// Set the expiration date of the conversation.
253
    ///
254
    /// The conversation is persisted, but only until the conversation is no
255
    /// longer marked as active (e.g. when a new conversation is started), and
256
    /// when the expiration date is reached.
257
    ///
258
    /// This differs from `--no-persist` in that the conversation can contain
259
    /// multiple turns, as long as it remains active and not expired.
260
    #[arg(long = "tmp", requires = "new")]
261
    expires_in: Option<Option<humantime::Duration>>,
262

263
    /// The tool to use.
264
    ///
265
    /// If a value is provided, the tool matching the value will be used.
266
    ///
267
    /// Note that this setting is *not* persisted across queries. To persist
268
    /// tool choice behavior, set the `assistant.tool_choice` field in a
269
    /// configuration file.
270
    #[arg(short = 'u', long = "tool-use")]
271
    tool_use: Option<Option<String>>,
272

273
    /// Disable tool use by the assistant.
274
    #[arg(short = 'U', long = "no-tool-use")]
275
    no_tool_use: bool,
276
}
277

278
impl Query {
279
    #[expect(clippy::too_many_lines)]
280
    pub(crate) async fn run(self, ctx: &mut Ctx, handle: Option<ConversationHandle>) -> Output {
×
281
        debug!("Running `query` command.");
×
282
        trace!(args = ?self, "Received arguments.");
×
283
        let now = ctx.now();
×
284
        let cfg = ctx.config();
×
285

286
        // Resolve the target conversation and acquire an exclusive lock.
287
        //
288
        // Three paths:
289
        // 1. --new: create a fresh conversation (already locked).
290
        // 2. --fork/--id/session: resolve an existing conversation, lock it.
291
        // 3. Lock contention: user picks "new" or "fork" from the prompt.
292
        let lock = self.acquire_lock(ctx, handle).await?;
×
293

294
        // Record this conversation as the session's active conversation.
295
        if let Some(session) = &ctx.session
×
296
            && let Err(error) = ctx
×
297
                .workspace
×
298
                .activate_session_conversation(session, lock.id(), now)
×
299
        {
300
            warn!(%error, "Failed to write session mapping.");
×
301
        }
×
302

303
        if let Some(delta) = get_config_delta_from_cli(&cfg, &lock)? {
×
304
            lock.as_mut()
×
305
                .update_events(|events| events.add_config_delta(delta));
×
306
        }
×
307

308
        let mut mcp_servers_handle = ctx.configure_active_mcp_servers().await?;
×
309

310
        let (conv_title, is_local) = {
×
311
            let m = lock.metadata();
×
312
            (m.title.clone(), m.user)
×
313
        };
×
314

315
        // Show conversation identity in the terminal title.
316
        if ctx.term.is_tty {
×
317
            set_terminal_title(lock.id(), conv_title.as_deref());
×
318
        }
×
319

320
        let cid = lock.id();
×
321
        let conversation_path = ctx.fs_backend.as_deref().map_or_else(
×
322
            || {
×
323
                ctx.workspace
×
324
                    .root()
×
325
                    .join(cid.to_dirname(conv_title.as_deref()))
×
326
            },
×
327
            |fs| fs.build_conversation_dir(&cid, conv_title.as_deref(), is_local),
×
328
        );
329

330
        let (query_file, mut editor_provided_config, chat_request) = lock
×
331
            .as_mut()
×
332
            .update_events(|stream| self.build_conversation(stream, &cfg, &conversation_path))?;
×
333

334
        let Some(chat_request) = chat_request else {
×
335
            // Empty query, early exit. Auto-persist happens on lock drop.
336
            if let Some(path) = query_file.as_deref() {
×
337
                fs::remove_file(path)?;
×
338
            }
×
339
            ctx.printer.println("Query is empty, ignoring.");
×
340
            return Ok(());
×
341
        };
342

343
        // If we have a query, and it was built from the editor, we print it
344
        // to the terminal for convenience, formatted as markdown.
345
        if query_file.is_some() {
×
346
            let pretty = ctx.printer.pretty_printing_enabled();
×
347
            let formatter = Formatter::with_width(cfg.style.markdown.wrap_width)
×
348
                .table_max_column_width(cfg.style.markdown.table_max_column_width)
×
349
                .theme(if pretty {
×
350
                    cfg.style.markdown.theme.as_deref()
×
351
                } else {
352
                    None
×
353
                })
354
                .pretty_hr(pretty && cfg.style.markdown.hr_style.is_line())
×
355
                .inline_code_bg(
×
356
                    cfg.style
×
357
                        .inline_code
×
358
                        .background
×
359
                        .map(crate::format::color_to_bg_param),
×
360
                );
361

362
            let formatted =
×
363
                formatter.format_terminal(&format!("{}\n\n---\n\n", chat_request.content))?;
×
364
            ctx.printer.println(formatted);
×
365
        }
×
366

367
        if !editor_provided_config.is_empty() {
×
368
            // Resolve any model aliases before storing in the stream so
369
            // that per-event configs always contain concrete model IDs.
370
            editor_provided_config.resolve_model_aliases(&cfg.providers.llm.aliases);
×
371
            lock.as_mut()
×
372
                .update_events(|events| events.add_config_delta(editor_provided_config));
×
373
        }
×
374

375
        let stream = lock.events().clone();
×
376

377
        // Generate title for new or empty conversations (including forks).
378
        if (self.is_new() || self.fork.is_some() || stream.is_empty())
×
379
            && ctx.term.args.persist
×
380
            && cfg.conversation.title.generate.auto
×
381
        {
382
            debug!("Generating title for new conversation");
×
383
            let mut stream = stream.clone();
×
384
            stream.start_turn(chat_request.clone());
×
385
            ctx.task_handler
×
386
                .spawn(TitleGeneratorTask::new(cid, stream, &cfg)?);
×
387
        }
×
388

389
        // Wait for all MCP servers to finish loading.
390
        while let Some(result) = mcp_servers_handle.join_next().await {
×
391
            result??;
×
392
        }
393

394
        let forced_tool = cfg.assistant.tool_choice.function_name();
×
395
        let tools =
×
396
            tool_definitions(cfg.conversation.tools.iter(), &ctx.mcp_client, forced_tool).await?;
×
397

398
        let attachment_futs: Vec<_> = cfg
×
399
            .conversation
×
400
            .attachments
×
401
            .iter()
×
402
            .map(jp_config::conversation::attachment::AttachmentConfig::to_url)
×
403
            .collect::<std::result::Result<Vec<_>, _>>()?
×
404
            .into_iter()
×
405
            .map(|url| register_attachment(ctx, url))
×
406
            .collect();
×
407
        let attachments: Vec<_> = futures::future::try_join_all(attachment_futs)
×
408
            .await?
×
409
            .into_iter()
×
410
            .flatten()
×
411
            .collect();
×
412

413
        debug!(count = attachments.len(), "Attachments loaded.");
×
414

415
        let thread = build_thread(stream, attachments, &cfg.assistant, !tools.is_empty())?;
×
416
        let root = ctx.workspace.root().to_path_buf();
×
417

418
        // Sanitize any structural issues (orphaned tool calls, missing
419
        // user messages, etc.) before sending the stream to the provider.
420
        lock.as_mut().update_events(ConversationStream::sanitize);
×
421

422
        // If a schema is provided, set it on the ChatRequest so the
423
        // provider uses its native structured output API.
424
        let mut chat_request = chat_request;
×
425
        if let Some(schema) = &self.schema {
×
426
            chat_request.schema = schema.as_object().cloned();
×
427
        }
×
428

429
        // Stamp the request with the configured user name so transcripts
430
        // attribute each turn correctly even when teammates with different
431
        // local configs continue the conversation. `None` falls back to a
432
        // generic label at render time.
NEW
433
        chat_request.author = cfg.user.name.clone();
×
434
        let turn_result = self
×
435
            .handle_turn(
×
436
                &cfg,
×
437
                &ctx.signals.receiver,
×
438
                &ctx.mcp_client,
×
439
                root,
×
440
                ctx.term.is_tty,
×
441
                &thread.attachments,
×
442
                &lock,
×
443
                cfg.assistant.tool_choice.clone(),
×
444
                &tools,
×
445
                ctx.printer.clone(),
×
446
                chat_request,
×
447
            )
×
448
            .await
×
449
            .map_err(|error| cmd::Error::from(error).with_persistence(true));
×
450

451
        // Extract structured data from the conversation after the turn.
452
        if self.schema.is_some() && turn_result.is_ok() {
×
453
            let data = lock.events().iter().rev().find_map(|e| {
×
454
                e.as_chat_response()
×
455
                    .and_then(ChatResponse::as_structured_data)
×
456
                    .cloned()
×
457
            });
×
458

459
            match data {
×
460
                Some(data) => print_json(&ctx.printer, &data),
×
461
                None => return Err(Error::MissingStructuredData.into()),
×
462
            }
463
        }
×
464

465
        // Clean up the query file, unless we got an error.
466
        if let Some(path) = query_file
×
467
            && turn_result.is_ok()
×
468
        {
469
            fs::remove_file(path)?;
×
470
        }
×
471

472
        turn_result
×
473
    }
×
474

475
    /// Declare what conversations this command needs.
476
    pub(crate) fn conversation_load_request(&self) -> ConversationLoadRequest {
×
477
        if self.is_new() {
×
478
            return ConversationLoadRequest::none();
×
479
        }
×
480

481
        ConversationLoadRequest::explicit_or_session_with_config(&self.target)
×
482
    }
×
483

484
    /// Build the chat request for this query.
485
    ///
486
    /// Returns the editor details and the [`ChatRequest`], if non-empty.
487
    /// The request is **not** added to the stream — that is the
488
    /// responsibility of [`TurnCoordinator::start_turn`].
489
    ///
490
    /// [`TurnCoordinator::start_turn`]: turn::TurnCoordinator::start_turn
491
    fn build_conversation(
×
492
        &self,
×
493
        stream: &mut ConversationStream,
×
494
        config: &AppConfig,
×
495
        conversation_root: &Utf8Path,
×
496
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig, Option<ChatRequest>)> {
×
497
        // If replaying, remove all events up-to-and-including the last
498
        // `ChatRequest` event, which we'll replay.
499
        //
500
        // If not replaying (or replaying but no chat request event exists), we
501
        // create a new `ChatRequest` event, to populate with either the
502
        // provided query, or the contents of the text editor.
503
        let mut chat_request = self
×
504
            .replay
×
505
            .then(|| stream.trim_chat_request())
×
506
            .flatten()
×
507
            .unwrap_or_default();
×
508

509
        // If stdin contains data, we prepend it to the chat request.
510
        let stdin = io::stdin();
×
511
        let piped = if stdin.is_terminal() {
×
512
            String::new()
×
513
        } else {
514
            stdin
×
515
                .lock()
×
516
                .lines()
×
517
                .map_while(std::result::Result::ok)
×
518
                .collect::<String>()
×
519
        };
520

521
        if !piped.is_empty() {
×
522
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
523
            *chat_request = format!("{piped}{sep}{chat_request}");
×
524
        }
×
525

526
        // If a query is provided, prepend it to the chat request. This is only
527
        // relevant for replays, otherwise the chat request is still empty, so
528
        // we replace it with the provided query.
529
        if let Some(text) = &self.query {
×
530
            let text = text.join(" ");
×
531
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
532
            *chat_request = format!("{text}{sep}{chat_request}");
×
533
        }
×
534

535
        let (query_file, editor_provided_config) = self.edit_message(
×
536
            &mut chat_request,
×
537
            stream,
×
538
            !piped.is_empty(),
×
539
            config,
×
540
            conversation_root,
×
541
        )?;
×
542

543
        if self.template {
×
544
            let mut env = Environment::empty();
×
545
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
546
            env.add_template("query", &chat_request.content)?;
×
547

548
            let tmpl = env.get_template("query")?;
×
549
            // TODO: supported nested variables
550
            for var in tmpl.undeclared_variables(false) {
×
551
                if config.template.values.contains_key(&var) {
×
552
                    continue;
×
553
                }
×
554

555
                return Err(Error::TemplateUndefinedVariable(var));
×
556
            }
557

558
            *chat_request = tmpl.render(&config.template.values)?;
×
559
        }
×
560

561
        Ok((
×
562
            query_file,
×
563
            editor_provided_config,
×
564
            (!chat_request.is_empty()).then_some(chat_request),
×
565
        ))
×
566
    }
×
567

568
    /// Create a new conversation and return an exclusive lock.
569
    fn create_new_conversation(&self, ctx: &mut Ctx) -> Result<ConversationLock> {
×
570
        let cfg = ctx.config();
×
571
        let ws = &mut ctx.workspace;
×
572

573
        let conversation = Conversation::default().with_local(self.is_local(&cfg.conversation));
×
574
        let lock =
×
575
            ws.create_and_lock_conversation(conversation, cfg.clone(), ctx.session.as_ref())?;
×
576
        let id = lock.id();
×
577

578
        if let Some(duration) = self.expires_in_duration() {
×
579
            let mut conv = lock.as_mut();
×
580
            conv.update_metadata(|m| {
×
581
                m.expires_at = chrono::Duration::from_std(duration)
×
582
                    .ok()
×
583
                    .and_then(|v| id.timestamp().checked_add_signed(v));
×
584
            });
×
585
            conv.flush()?;
×
586
        }
×
587

588
        debug!(
×
589
            id = id.to_string(),
×
590
            local = self.is_local(&cfg.conversation),
×
591
            expires_in = self.expires_in_duration().map_or_else(
×
592
                || "when inactive".to_owned(),
×
593
                |v| humantime::format_duration(v).to_string()
×
594
            ),
595
            "Creating new conversation."
596
        );
597

598
        Ok(lock)
×
599
    }
×
600

601
    // Open the editor for the query, if requested.
602
    fn edit_message(
×
603
        &self,
×
604
        request: &mut ChatRequest,
×
605
        stream: &mut ConversationStream,
×
606
        piped: bool,
×
607
        config: &AppConfig,
×
608
        conversation_root: &Utf8Path,
×
609
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig)> {
×
610
        // If there is no query provided, but the user explicitly requested not
611
        // to open the editor, we populate the query with a default message,
612
        // since most LLM providers do not support empty queries.
613
        //
614
        // See `force_no_edit` why this can be useful.
615
        if request.is_empty() && self.force_no_edit() {
×
616
            // If the last event in the stream is a `ChatRequest`, we don't add
617
            // anything, and simply "replay" the last message in the
618
            // conversation.
619
            //
620
            // Otherwise we add a default "continue" message.
621
            if let Some(last) = stream.pop_if(ConversationEvent::is_chat_request)
×
622
                && let Some(req) = last.into_inner().into_chat_request()
×
623
            {
×
624
                *request = req;
×
625
            } else {
×
626
                "continue".clone_into(request);
×
627
            }
×
628
        }
×
629

630
        // If a query is provided, and editing is not explicitly requested, or
631
        // in addition to the query, stdin contains data, we omit opening the
632
        // editor.
633
        if (self.query.as_ref().is_some_and(|v| !v.is_empty()) || !piped)
×
634
            && !self.force_edit()
×
635
            && !request.is_empty()
×
636
        {
637
            return Ok((None, PartialAppConfig::empty()));
×
638
        }
×
639

640
        let editor = match config.editor.command() {
×
641
            None if !request.is_empty() => return Ok((None, PartialAppConfig::empty())),
×
642
            None => return Err(Error::MissingEditor),
×
643
            Some(cmd) => cmd,
×
644
        };
645

646
        let (content, query_file, editor_provided_config) = editor::edit_query(
×
647
            config,
×
648
            conversation_root,
×
649
            stream,
×
650
            request.as_str(),
×
651
            editor,
×
652
            None,
×
653
        )?;
×
654
        request.content = content;
×
655

656
        Ok((Some(query_file), editor_provided_config))
×
657
    }
×
658

659
    /// Handle a single turn of conversation with the LLM.
660
    #[expect(clippy::too_many_arguments)]
661
    async fn handle_turn(
×
662
        &self,
×
663
        cfg: &AppConfig,
×
664
        signals: &SignalRx,
×
665
        mcp_client: &jp_mcp::Client,
×
666
        root: Utf8PathBuf,
×
667
        is_tty: bool,
×
668
        attachments: &[Attachment],
×
669
        lock: &ConversationLock,
×
670
        tool_choice: ToolChoice,
×
671
        tools: &[ToolDefinition],
×
672
        printer: Arc<Printer>,
×
673
        chat_request: ChatRequest,
×
674
    ) -> Result<()> {
×
675
        let model_id = cfg.assistant.model.id.resolved();
×
676
        let provider: Arc<dyn jp_llm::Provider> = Arc::from(provider::get_provider(
×
677
            model_id.provider,
×
678
            &cfg.providers.llm,
×
679
        )?);
×
680
        debug!(model = %model_id, "Fetching model details.");
×
681
        let model = provider.model_details(&model_id.name).await?;
×
682
        debug!(model = model.name(), "Model details resolved.");
×
683

684
        // Build docs map from the resolved definitions for describe_tools.
685
        let docs_map: IndexMap<String, ToolDocs> = tools
×
686
            .iter()
×
687
            .map(|t| (t.name.clone(), t.docs.clone()))
×
688
            .collect();
×
689
        let builtin_executors =
×
690
            BuiltinExecutors::new().register("describe_tools", DescribeTools::new(docs_map));
×
691
        let executor_source = TerminalExecutorSource::new(builtin_executors, tools);
×
692
        let tool_coordinator =
×
693
            ToolCoordinator::new(cfg.conversation.tools.clone(), Box::new(executor_source));
×
694
        let prompt_backend = Arc::new(TerminalPromptBackend);
×
695

696
        run_turn_loop(
×
697
            provider,
×
698
            &model,
×
699
            cfg,
×
700
            signals,
×
701
            mcp_client,
×
702
            &root,
×
703
            is_tty,
×
704
            attachments,
×
705
            lock,
×
706
            tool_choice,
×
707
            tools,
×
708
            printer,
×
709
            prompt_backend,
×
710
            tool_coordinator,
×
711
            chat_request,
×
712
        )
×
713
        .await
×
714
    }
×
715

716
    /// Returns `true` if editing is explicitly disabled.
717
    ///
718
    /// This signals that even if no query is provided, no editor should be
719
    /// opened, but instead an empty query should be used.
720
    ///
721
    /// This can be used for example when requesting a tool call without needing
722
    /// additional context to be provided.
723
    fn force_no_edit(&self) -> bool {
×
724
        self.no_edit || matches!(self.edit, Some(Some(Editor::Disabled)))
×
725
    }
×
726

727
    /// Returns `true` if editing is explicitly enabled.
728
    ///
729
    /// This means the `--edit` flag was provided (but not `--edit=false`),
730
    /// which means the editor should be opened, regardless of whether a query
731
    /// is provided as an argument.
732
    fn force_edit(&self) -> bool {
×
733
        !self.force_no_edit() && self.edit.is_some()
×
734
    }
×
735

736
    #[must_use]
737
    fn is_local(&self, cfg: &ConversationConfig) -> bool {
×
738
        (self.local || cfg.start_local) && !self.no_local
×
739
    }
×
740

741
    #[must_use]
742
    fn is_new(&self) -> bool {
×
743
        self.new_conversation
×
744
    }
×
745

746
    #[must_use]
747
    fn expires_in_duration(&self) -> Option<Duration> {
×
748
        self.expires_in?
×
749
            .map(Duration::from)
×
750
            .or_else(|| Some(Duration::new(0, 0)))
×
751
    }
×
752

753
    async fn acquire_lock(
×
754
        &self,
×
755
        ctx: &mut Ctx,
×
756
        handle: Option<ConversationHandle>,
×
757
    ) -> Result<ConversationLock> {
×
758
        // Handle --new: create a fresh conversation.
759
        if self.is_new() {
×
760
            return self.create_new_conversation(ctx);
×
761
        }
×
762

763
        let handle = handle.ok_or(Error::NoConversationTarget)?;
×
764

765
        // Handle --fork: fork the conversation before locking.
766
        if let Some(fork_turns) = &self.fork {
×
767
            return fork_conversation(ctx, &handle, *fork_turns);
×
768
        }
×
769

770
        let req = LockRequest::from_ctx(handle, ctx)
×
771
            .allow_new(true)
×
772
            .allow_fork(true);
×
773

774
        match acquire_lock(req).await? {
×
775
            LockOutcome::Acquired(lock) => Ok(lock),
×
776
            LockOutcome::NewConversation => self.create_new_conversation(ctx),
×
777
            LockOutcome::ForkConversation(handle) => fork_conversation(ctx, &handle, None),
×
778
        }
779
    }
×
780
}
781

782
/// A single tool selection directive from the CLI.
783
///
784
/// Directives are evaluated left-to-right, allowing users to compose tool sets
785
/// precisely (e.g. `--no-tools --tool=write --no-tools=fs_modify_file`).
786
#[derive(Debug, Clone, PartialEq, Eq)]
787
enum ToolDirective {
788
    EnableAll,
789
    DisableAll,
790
    Enable(String),
791
    Disable(String),
792
}
793

794
impl ToolDirective {
795
    /// Returns the single-tool directive as a string slice.
796
    #[must_use]
797
    fn as_single(&self) -> Option<&str> {
19✔
798
        match self {
19✔
799
            Self::Enable(name) | Self::Disable(name) => Some(name.as_str()),
10✔
800
            _ => None,
9✔
801
        }
802
    }
19✔
803
}
804

805
/// Ordered sequence of tool directives parsed from `--tool` and `--no-tools`.
806
///
807
/// Implements manual [`clap::Args`] and [`clap::FromArgMatches`] to recover the
808
/// position of each flag value using [`ArgMatches::indices_of`], then merges
809
/// and sorts them by index into a single ordered list.
810
///
811
/// [`ArgMatches::indices_of`]: clap::ArgMatches::indices_of
812
#[derive(Debug, Clone, Default)]
813
struct ToolDirectives(Vec<ToolDirective>);
814

815
impl std::ops::Deref for ToolDirectives {
816
    type Target = [ToolDirective];
817

818
    fn deref(&self) -> &Self::Target {
49✔
819
        &self.0
49✔
820
    }
49✔
821
}
822

823
impl clap::FromArgMatches for ToolDirectives {
824
    fn from_arg_matches(matches: &clap::ArgMatches) -> std::result::Result<Self, clap::Error> {
×
825
        let tool_values: Vec<String> = matches
×
826
            .get_many("tools")
×
827
            .map(|v| v.cloned().collect())
×
828
            .unwrap_or_default();
×
829
        let tool_indices: Vec<_> = matches
×
830
            .indices_of("tools")
×
831
            .map(Iterator::collect)
×
832
            .unwrap_or_default();
×
833

834
        let no_tool_values: Vec<String> = matches
×
835
            .get_many("no_tools")
×
836
            .map(|v| v.cloned().collect())
×
837
            .unwrap_or_default();
×
838
        let no_tool_indices: Vec<_> = matches
×
839
            .indices_of("no_tools")
×
840
            .map(Iterator::collect)
×
841
            .unwrap_or_default();
×
842

843
        let mut indexed = vec![];
×
844
        for (val, idx) in tool_values.into_iter().zip(tool_indices) {
×
845
            let directive = if val.is_empty() {
×
846
                ToolDirective::EnableAll
×
847
            } else {
848
                ToolDirective::Enable(val)
×
849
            };
850
            indexed.push((idx, directive));
×
851
        }
852

853
        for (val, idx) in no_tool_values.into_iter().zip(no_tool_indices) {
×
854
            let directive = if val.is_empty() {
×
855
                ToolDirective::DisableAll
×
856
            } else {
857
                ToolDirective::Disable(val)
×
858
            };
859
            indexed.push((idx, directive));
×
860
        }
861

862
        indexed.sort_by_key(|(idx, _)| *idx);
×
863
        Ok(Self(indexed.into_iter().map(|(_, d)| d).collect()))
×
864
    }
×
865

866
    fn update_from_arg_matches(
×
867
        &mut self,
×
868
        matches: &clap::ArgMatches,
×
869
    ) -> std::result::Result<(), clap::Error> {
×
870
        *self = Self::from_arg_matches(matches)?;
×
871
        Ok(())
×
872
    }
×
873
}
874

875
impl clap::Args for ToolDirectives {
876
    fn augment_args(cmd: clap::Command) -> clap::Command {
1✔
877
        cmd.arg(
1✔
878
            clap::Arg::new("tools")
1✔
879
                .short('t')
1✔
880
                .long("tool")
1✔
881
                .alias("tools")
1✔
882
                .help("The tool(s) to enable")
1✔
883
                .long_help(
1✔
884
                    "The tool(s) to enable.\n\nIf an existing tool is configured with a matching \
885
                     name, it will be enabled for the duration of the query.\n\nIf no arguments \
886
                     are provided, all configured tools will be enabled.\n\nYou can provide this \
887
                     flag multiple times to enable multiple tools. Flags are evaluated \
888
                     left-to-right, so `--no-tools --tool=write` first disables everything, then \
889
                     re-enables only 'write'.",
890
                )
891
                .action(ArgAction::Append)
1✔
892
                .num_args(0..=1)
1✔
893
                .default_missing_value(""),
1✔
894
        )
895
        .arg(
1✔
896
            clap::Arg::new("no_tools")
1✔
897
                .short('T')
1✔
898
                .long("no-tool")
1✔
899
                .alias("no-tools")
1✔
900
                .help("Disable tool(s)")
1✔
901
                .long_help(
1✔
902
                    "Disable tool(s).\n\nIf provided without a value, all enabled tools will be \
903
                     disabled, otherwise pass the argument multiple times to disable one or more \
904
                     tools.\n\nFlags are evaluated left-to-right together with `--tool`.",
905
                )
906
                .action(ArgAction::Append)
1✔
907
                .num_args(0..=1)
1✔
908
                .default_missing_value(""),
1✔
909
        )
910
    }
1✔
911

912
    fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
×
913
        Self::augment_args(cmd)
×
914
    }
×
915
}
916

917
/// Fork a conversation and return the new conversation's lock.
918
fn fork_conversation(
×
919
    ctx: &mut Ctx,
×
920
    source: &ConversationHandle,
×
921
    fork_turns: Option<usize>,
×
922
) -> Result<ConversationLock> {
×
923
    fork::fork_conversation(ctx, source, |events| {
×
924
        if let Some(n) = fork_turns {
×
925
            events.retain_last_turns(n);
×
926
        }
×
927
    })
×
928
}
×
929

930
fn get_config_delta_from_cli(
×
931
    cfg: &AppConfig,
×
932
    lock: &ConversationLock,
×
933
) -> Result<Option<PartialAppConfig>> {
×
934
    let partial = lock
×
935
        .events()
×
936
        .config()
×
937
        .map(|c| c.to_partial())
×
938
        .map_err(jp_conversation::Error::from)?;
×
939

940
    let partial = partial.delta(cfg.to_partial());
×
941
    if partial.is_empty() {
×
942
        return Ok(None);
×
943
    }
×
944

945
    Ok(Some(partial))
×
946
}
×
947

948
impl IntoPartialAppConfig for Query {
949
    fn apply_cli_config(
13✔
950
        &self,
13✔
951
        workspace: Option<&Workspace>,
13✔
952
        mut partial: PartialAppConfig,
13✔
953
        merged_config: Option<&PartialAppConfig>,
13✔
954
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
13✔
955
        let Self {
956
            model,
13✔
957
            template: _,
958
            schema: _,
959
            replay: _,
960
            new_conversation: _,
961
            local: _,
962
            no_local: _,
963
            attachments,
13✔
964
            edit,
13✔
965
            no_edit,
13✔
966
            tool_use,
13✔
967
            no_tool_use,
13✔
968
            query: _,
969
            parameters,
13✔
970
            hide_reasoning,
13✔
971
            hide_tool_calls,
13✔
972
            tool_directives,
13✔
973
            reasoning,
13✔
974
            no_reasoning,
13✔
975
            expires_in: _,
976
            target: _,
977
            fork: _,
978
        } = &self;
13✔
979

980
        apply_model(&mut partial, model.as_deref(), merged_config);
13✔
981
        apply_editor(&mut partial, edit.as_ref().map(|v| v.as_ref()), *no_edit);
13✔
982

983
        // Inject builtin tool configs before tool-enable processing.
984
        for (name, config) in tool::builtins::all() {
13✔
985
            partial
13✔
986
                .conversation
13✔
987
                .tools
13✔
988
                .tools
13✔
989
                .entry(name)
13✔
990
                .or_insert(config);
13✔
991
        }
13✔
992

993
        apply_enable_tools(&mut partial, tool_directives, merged_config)?;
13✔
994
        apply_tool_use(
13✔
995
            &mut partial,
13✔
996
            tool_use.as_ref().map(|v| v.as_deref()),
13✔
997
            *no_tool_use,
13✔
998
        )?;
×
999
        apply_attachments(&mut partial, attachments, workspace)?;
13✔
1000
        apply_reasoning(&mut partial, reasoning.as_ref(), *no_reasoning);
13✔
1001

1002
        for kv in parameters.clone() {
13✔
1003
            partial.assistant.model.parameters.assign(kv)?;
×
1004
        }
1005

1006
        if *hide_reasoning {
13✔
1007
            partial.style.reasoning.display = Some(ReasoningDisplayConfig::Hidden);
×
1008
        }
13✔
1009

1010
        if *hide_tool_calls {
13✔
1011
            partial.style.tool_call.show = Some(false);
×
1012
        }
13✔
1013

1014
        Ok(partial)
13✔
1015
    }
13✔
1016

1017
    fn apply_conversation_config(
×
1018
        &self,
×
1019
        workspace: &Workspace,
×
1020
        partial: PartialAppConfig,
×
1021
        _: Option<&PartialAppConfig>,
×
1022
        handle: &ConversationHandle,
×
1023
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
1024
        let config = workspace.events(handle)?.config().map(|c| c.to_partial())?;
×
1025

1026
        load_partial(partial, config).map_err(Into::into)
×
1027
    }
×
1028
}
1029

1030
/// Build the sorted list of system prompt sections from assistant config.
1031
///
1032
/// Used by both [`build_thread`] and [`LlmInquiryBackend`] construction
1033
/// to ensure the inquiry backend sees the same sections as the main thread.
1034
///
1035
/// [`LlmInquiryBackend`]: crate::cmd::query::tool::inquiry::LlmInquiryBackend
1036
pub(super) fn build_sections(assistant: &AssistantConfig, has_tools: bool) -> Vec<SectionConfig> {
83✔
1037
    let mut sections: Vec<_> = assistant.system_prompt_sections.clone();
83✔
1038
    sections.extend(
83✔
1039
        assistant
83✔
1040
            .instructions
83✔
1041
            .iter()
83✔
1042
            .map(InstructionsConfig::to_section),
83✔
1043
    );
1044

1045
    if has_tools {
83✔
1046
        let tool_section = InstructionsConfig::default()
39✔
1047
            .with_title("Tool Usage")
39✔
1048
            .with_description("How to leverage the tools available to you.".to_string())
39✔
1049
            .with_item("Use all the tools available to you to give the best possible answer.")
39✔
1050
            .with_item("Verify the tool name, description and parameters are correct.")
39✔
1051
            .with_item(
39✔
1052
                "Even if you've reasoned yourself towards a solution, use any available tool to \
39✔
1053
                 verify your answer.",
39✔
1054
            )
39✔
1055
            .to_section();
39✔
1056

39✔
1057
        sections.push(tool_section);
39✔
1058
    }
44✔
1059

1060
    sections.sort_by_key(|s| s.position);
83✔
1061
    sections
83✔
1062
}
83✔
1063

1064
fn build_thread(
53✔
1065
    events: ConversationStream,
53✔
1066
    attachments: Vec<Attachment>,
53✔
1067
    assistant: &AssistantConfig,
53✔
1068
    has_tools: bool,
53✔
1069
) -> Result<Thread> {
53✔
1070
    let sections = build_sections(assistant, has_tools);
53✔
1071

1072
    let mut thread_builder = ThreadBuilder::default()
53✔
1073
        .with_sections(sections)
53✔
1074
        .with_attachments(attachments)
53✔
1075
        .with_events(events);
53✔
1076

1077
    if let Some(system_prompt) = assistant.system_prompt.clone() {
53✔
1078
        thread_builder = thread_builder.with_system_prompt(system_prompt);
53✔
1079
    }
53✔
1080

1081
    Ok(thread_builder.build()?)
53✔
1082
}
53✔
1083

1084
/// Apply the CLI model configuration to the partial configuration.
1085
fn apply_model(partial: &mut PartialAppConfig, model: Option<&str>, _: Option<&PartialAppConfig>) {
13✔
1086
    let Some(id) = model else { return };
13✔
1087

1088
    partial.assistant.model.id = id.into();
×
1089
}
13✔
1090

1091
/// Apply the CLI editor configuration to the partial configuration.
1092
fn apply_editor(partial: &mut PartialAppConfig, editor: Option<Option<&Editor>>, no_edit: bool) {
13✔
1093
    let Some(Some(editor)) = editor else {
×
1094
        return;
13✔
1095
    };
1096

1097
    match (no_edit, editor) {
×
1098
        (true, _) | (_, Editor::Disabled) => {
×
1099
            partial.editor.cmd = None;
×
1100
            partial.editor.envs = None;
×
1101
        }
×
1102
        (_, Editor::Default) => {}
×
1103
        (_, Editor::Command(cmd)) => partial.editor.cmd = Some(cmd.clone()),
×
1104
    }
1105
}
13✔
1106

1107
fn apply_enable_tools(
13✔
1108
    partial: &mut PartialAppConfig,
13✔
1109
    directives: &ToolDirectives,
13✔
1110
    merged_config: Option<&PartialAppConfig>,
13✔
1111
) -> BoxedResult<()> {
13✔
1112
    if directives.is_empty() {
13✔
1113
        return Ok(());
1✔
1114
    }
12✔
1115

1116
    let existing_tools = merged_config.map_or(&partial.conversation.tools.tools, |v| {
12✔
1117
        &v.conversation.tools.tools
×
1118
    });
×
1119

1120
    // Validate all named tools exist.
1121
    let missing: HashSet<_> = directives
12✔
1122
        .iter()
12✔
1123
        .filter_map(ToolDirective::as_single)
12✔
1124
        .filter(|name| !existing_tools.contains_key(*name))
12✔
1125
        .collect();
12✔
1126

1127
    if missing.len() == 1 {
12✔
1128
        return Err(ToolError::NotFound {
×
1129
            name: missing.iter().next().unwrap().to_string(),
×
1130
        }
×
1131
        .into());
×
1132
    } else if !missing.is_empty() {
12✔
1133
        return Err(ToolError::NotFoundN {
×
1134
            names: missing.into_iter().map(ToString::to_string).collect(),
×
1135
        }
×
1136
        .into());
×
1137
    }
12✔
1138

1139
    // Validate that core tools are not disabled by name.
1140
    for d in directives.iter() {
19✔
1141
        if let ToolDirective::Disable(name) = d
19✔
1142
            && let Some(tool) = partial.conversation.tools.tools.get(name.as_str())
3✔
1143
            && tool.enable.is_some_and(Enable::is_always)
3✔
1144
        {
1145
            return Err(format!("Tool '{name}' is a system tool and cannot be disabled").into());
×
1146
        }
19✔
1147
    }
1148

1149
    // Apply directives left-to-right.
1150
    for d in directives.iter() {
19✔
1151
        match d {
19✔
1152
            ToolDirective::EnableAll => {
1153
                partial
5✔
1154
                    .conversation
5✔
1155
                    .tools
5✔
1156
                    .tools
5✔
1157
                    .iter_mut()
5✔
1158
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_explicit))
25✔
1159
                    .for_each(|(_, v)| v.enable = Some(Enable::On));
21✔
1160
            }
1161
            ToolDirective::DisableAll => {
1162
                partial
4✔
1163
                    .conversation
4✔
1164
                    .tools
4✔
1165
                    .tools
4✔
1166
                    .iter_mut()
4✔
1167
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_always))
20✔
1168
                    .for_each(|(_, v)| v.enable = Some(Enable::Off));
17✔
1169
            }
1170
            ToolDirective::Enable(name) => {
7✔
1171
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
7✔
1172
                    tool.enable = Some(Enable::On);
7✔
1173
                }
7✔
1174
            }
1175
            ToolDirective::Disable(name) => {
3✔
1176
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
3✔
1177
                    tool.enable = Some(Enable::Off);
3✔
1178
                }
3✔
1179
            }
1180
        }
1181
    }
1182

1183
    Ok(())
12✔
1184
}
13✔
1185

1186
/// Apply the CLI tool use configuration to the partial configuration.
1187
///
1188
/// NOTE: This has to run *after* `apply_enable_tools` because it will return an
1189
/// error if the tool of choice is not enabled.
1190
fn apply_tool_use(
13✔
1191
    partial: &mut PartialAppConfig,
13✔
1192
    tool_choice: Option<Option<&str>>,
13✔
1193
    no_tool_choice: bool,
13✔
1194
) -> BoxedResult<()> {
13✔
1195
    if no_tool_choice || matches!(tool_choice, Some(Some("false"))) {
13✔
1196
        partial.assistant.tool_choice = Some(ToolChoice::None);
×
1197
        return Ok(());
×
1198
    }
13✔
1199

1200
    let Some(tool) = tool_choice else {
13✔
1201
        return Ok(());
13✔
1202
    };
1203

1204
    partial.assistant.tool_choice = match tool {
×
1205
        None | Some("true") => Some(ToolChoice::Required),
×
1206
        Some(v) => {
×
1207
            if !partial
×
1208
                .conversation
×
1209
                .tools
×
1210
                .tools
×
1211
                .iter()
×
1212
                .filter(|(_, cfg)| cfg.enable.is_some_and(Enable::is_on))
×
1213
                .any(|(name, _)| name == v)
×
1214
            {
1215
                return Err(format!("tool choice '{v}' does not match any enabled tools").into());
×
1216
            }
×
1217

1218
            Some(ToolChoice::Function(v.to_owned()))
×
1219
        }
1220
    };
1221

1222
    Ok(())
×
1223
}
13✔
1224

1225
/// Apply the CLI attachments to the partial configuration.
1226
fn apply_attachments(
13✔
1227
    partial: &mut PartialAppConfig,
13✔
1228
    attachments: &[AttachmentUrlOrPath],
13✔
1229
    workspace: Option<&Workspace>,
13✔
1230
) -> Result<()> {
13✔
1231
    let root = workspace.map(Workspace::root);
13✔
1232
    let attachments = attachments
13✔
1233
        .iter()
13✔
1234
        .map(|v| v.parse(root))
13✔
1235
        .collect::<Result<Vec<_>>>()?;
13✔
1236

1237
    partial
13✔
1238
        .conversation
13✔
1239
        .attachments
13✔
1240
        .extend(attachments.into_iter().map(Into::into));
13✔
1241

1242
    Ok(())
13✔
1243
}
13✔
1244

1245
/// Apply the CLI reasoning configuration to the partial configuration.
1246
fn apply_reasoning(
13✔
1247
    partial: &mut PartialAppConfig,
13✔
1248
    reasoning: Option<&ReasoningConfig>,
13✔
1249
    no_reasoning: bool,
13✔
1250
) {
13✔
1251
    if no_reasoning {
13✔
1252
        partial.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Off);
×
1253
        return;
×
1254
    }
13✔
1255

1256
    let Some(reasoning) = reasoning else {
13✔
1257
        return;
13✔
1258
    };
1259

1260
    partial.assistant.model.parameters.reasoning = Some(match reasoning {
×
1261
        ReasoningConfig::Off => PartialReasoningConfig::Off,
×
1262
        ReasoningConfig::Auto => PartialReasoningConfig::Auto,
×
1263
        ReasoningConfig::Custom(custom) => PartialCustomReasoningConfig {
×
1264
            effort: Some(custom.effort),
×
1265
            exclude: Some(custom.exclude),
×
1266
        }
×
1267
        .into(),
×
1268
    });
1269
}
13✔
1270

1271
/// Set the terminal title to show the active conversation.
1272
fn set_terminal_title(id: ConversationId, title: Option<&str>) {
×
1273
    let display = match title {
×
1274
        Some(t) => format!("{id}: {t}"),
×
1275
        None => id.to_string(),
×
1276
    };
1277
    jp_term::osc::set_title(display);
×
1278
}
×
1279

1280
/// Parse a schema string as either a concise DSL or raw JSON Schema.
1281
#[expect(clippy::needless_pass_by_value)]
1282
fn parse_schema(s: String) -> Result<schemars::Schema> {
×
1283
    crate::schema::parse_schema_dsl(&s)
×
1284
        .map_err(|e| Error::Schema(e.to_string()))?
×
1285
        .try_into()
×
1286
        .map_err(Into::into)
×
1287
}
×
1288

1289
/// Parse the `--fork` value. Empty string means "all turns", a number means
1290
/// "keep last N turns".
1291
fn parse_fork_turns(s: &str) -> std::result::Result<Option<usize>, String> {
×
1292
    if s.is_empty() {
×
1293
        return Ok(None);
×
1294
    }
×
1295
    s.parse::<usize>()
×
1296
        .map(Some)
×
1297
        .map_err(|_| format!("expected a positive integer, got '{s}'"))
×
1298
}
×
1299

1300
fn string_or_path(s: &str) -> Result<String> {
×
1301
    if let Some(s) = s
×
1302
        .strip_prefix(PATH_STRING_PREFIX)
×
1303
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
1304
    {
1305
        return fs::read_to_string(s).map_err(Into::into);
×
1306
    }
×
1307

1308
    Ok(s.to_owned())
×
1309
}
×
1310

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

© 2026 Coveralls, Inc