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

dcdpr / jp / 25827631389

13 May 2026 09:31PM UTC coverage: 66.696% (+0.2%) from 66.49%
25827631389

push

github

web-flow
fix(attachment_internal, cli): Skip missing conversations on query (#635)

Previously, if a `jp://` attachment referenced a conversation that had
been archived or removed, `jp query` would hard-fail with an error,
making the conversation unusable until the stale attachment was manually
cleaned up.

This change introduces a dedicated `ResolveError` enum in
`jp_attachment_internal`, replacing the opaque `Box<dyn Error>` return
type on `resolve()`. The new `ConversationMissing` variant is surfaced
separately from all other failures (`Other`), giving callers a
structured way to distinguish "conversation is gone" from real errors.

On the CLI side, a new `load_conversation_attachments` helper in
`jp_cli` uses this distinction: when a `jp://` attachment resolves to
`ConversationMissing`, it logs a warning and skips the reference instead
of aborting the query. Any other failure (invalid URI, I/O error, etc.)
is still treated as a hard error. The `query` command now calls this
helper instead of resolving attachments directly via
`register_attachment`.

A corresponding `AttachmentConversationMissing` error variant is added
to the CLI error type so the missing-conversation case can be matched
exhaustively throughout the call stack.

---------

Signed-off-by: Jean Mertz <git@jeanmertz.com>

32 of 51 new or added lines in 4 files covered. (62.75%)

92 existing lines in 4 files now uncovered.

26135 of 39185 relevant lines covered (66.7%)

206.41 hits per line

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

52.87
/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_printer::Printer;
94
use jp_task::task::TitleGeneratorTask;
95
use jp_workspace::{ConversationHandle, ConversationLock, Workspace};
96
use minijinja::{Environment, UndefinedBehavior};
97
use tool::{TerminalExecutorSource, ToolCoordinator};
98
use tracing::{debug, trace, warn};
99
use turn_loop::run_turn_loop;
100

101
use super::{
102
    ConversationLoadRequest, Output, attachment::load_conversation_attachments,
103
    conversation_id::FlagIds, lock::LockOutcome,
104
};
105
use crate::{
106
    Ctx, PATH_STRING_PREFIX,
107
    cmd::{
108
        self,
109
        conversation::fork,
110
        lock::{LockRequest, acquire_lock},
111
    },
112
    ctx::IntoPartialAppConfig,
113
    editor::{self, Editor},
114
    error::{Error, Result},
115
    output::print_json,
116
    parser::AttachmentUrlOrPath,
117
    render::TurnView,
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
    /// Set a custom title for the conversation.
264
    ///
265
    /// Applied to the resolved conversation (new, forked, or resumed) before
266
    /// the turn runs. Skips title auto-generation for new conversations —
267
    /// your title wins. Mutually exclusive with `--no-title`.
268
    #[arg(long = "title", conflicts_with = "no_title")]
269
    title: Option<String>,
270

271
    /// Disable the title for the conversation.
272
    ///
273
    /// Clears any existing title on the resolved conversation (new, forked,
274
    /// or resumed) and skips auto-generation for this run. Mutually
275
    /// exclusive with `--title`.
276
    #[arg(long = "no-title", conflicts_with = "title")]
277
    no_title: bool,
278

279
    /// The tool to use.
280
    ///
281
    /// If a value is provided, the tool matching the value will be used.
282
    ///
283
    /// Note that this setting is *not* persisted across queries. To persist
284
    /// tool choice behavior, set the `assistant.tool_choice` field in a
285
    /// configuration file.
286
    #[arg(short = 'u', long = "tool-use")]
287
    tool_use: Option<Option<String>>,
288

289
    /// Disable tool use by the assistant.
290
    #[arg(short = 'U', long = "no-tool-use")]
291
    no_tool_use: bool,
292
}
293

294
impl Query {
295
    #[expect(clippy::too_many_lines)]
296
    pub(crate) async fn run(self, ctx: &mut Ctx, handle: Option<ConversationHandle>) -> Output {
2✔
297
        debug!("Running `query` command.");
2✔
298
        trace!(args = ?self, "Received arguments.");
2✔
299
        let now = ctx.now();
2✔
300
        let cfg = ctx.config();
2✔
301

302
        // Resolve the target conversation and acquire an exclusive lock.
303
        //
304
        // Three paths:
305
        // 1. --new: create a fresh conversation (already locked).
306
        // 2. --fork/--id/session: resolve an existing conversation, lock it.
307
        // 3. Lock contention: user picks "new" or "fork" from the prompt.
308
        let lock = self.acquire_lock(ctx, handle).await?;
2✔
309

310
        // The two flags are mutually exclusive (enforced by clap), and the
311
        // resolved conversation may be new, freshly forked (which clones the
312
        // source's metadata, including any title), or resumed.
313
        apply_title_override(&lock, self.title.as_deref(), self.no_title);
2✔
314

315
        // Record this conversation as the session's active conversation.
316
        if let Some(session) = &ctx.session
2✔
317
            && let Err(error) = ctx
2✔
318
                .workspace
2✔
319
                .activate_session_conversation(&lock, session, now)
2✔
320
        {
321
            warn!(%error, "Failed to record activation.");
×
322
        }
2✔
323

324
        if let Some(delta) = get_config_delta_from_cli(&cfg, &lock)? {
2✔
325
            lock.as_mut()
2✔
326
                .update_events(|events| events.add_config_delta(delta));
2✔
327
        }
×
328

329
        let mut mcp_servers_handle = ctx.configure_active_mcp_servers().await?;
2✔
330

331
        let (conv_title, is_local) = {
2✔
332
            let m = lock.metadata();
2✔
333
            (m.title.clone(), m.user)
2✔
334
        };
2✔
335

336
        // Show conversation identity in the terminal title.
337
        if ctx.term.is_tty {
2✔
338
            set_terminal_title(lock.id(), conv_title.as_deref());
×
339
        }
2✔
340

341
        let cid = lock.id();
2✔
342
        let conversation_path = ctx.fs_backend.as_deref().map_or_else(
2✔
343
            || {
×
344
                ctx.workspace
×
345
                    .root()
×
346
                    .join(cid.to_dirname(conv_title.as_deref()))
×
347
            },
×
348
            |fs| fs.build_conversation_dir(&cid, conv_title.as_deref(), is_local),
2✔
349
        );
350

351
        let (query_file, mut editor_provided_config, chat_request) = lock
2✔
352
            .as_mut()
2✔
353
            .update_events(|stream| self.build_conversation(stream, &cfg, &conversation_path))?;
2✔
354

355
        let Some(chat_request) = chat_request else {
×
356
            // Empty query, early exit. Auto-persist happens on lock drop.
357
            if let Some(path) = query_file.as_deref() {
×
358
                fs::remove_file(path)?;
×
359
            }
×
360
            ctx.printer.println("Query is empty, ignoring.");
×
361
            return Ok(());
×
362
        };
363

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

372
        let stream = lock.events().clone();
×
373

374
        // Generate title for new or empty conversations (including forks).
375
        // Skip when `--title` or `--no-title` was provided (the user already
376
        // expressed an intent for the title), or when the resolved config
377
        // has auto-generation disabled.
378
        if (self.is_new() || self.fork.is_some() || stream.is_empty())
×
379
            && ctx.term.args.persist
×
380
            && self.title.is_none()
×
381
            && !self.no_title
×
382
            && cfg.conversation.title.generate.auto
×
383
        {
384
            debug!("Generating title for new conversation");
×
385
            let mut stream = stream.clone();
×
386
            stream.start_turn(chat_request.clone());
×
387
            ctx.task_handler
×
388
                .spawn(TitleGeneratorTask::new(cid, stream, &cfg)?);
×
389
        }
×
390

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

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

NEW
400
        let attachment_urls: Vec<_> = cfg
×
401
            .conversation
×
402
            .attachments
×
403
            .iter()
×
404
            .map(jp_config::conversation::attachment::AttachmentConfig::to_url)
×
NEW
405
            .collect::<std::result::Result<Vec<_>, _>>()?;
×
NEW
406
        let attachments = load_conversation_attachments(ctx, attachment_urls).await?;
×
407

408
        debug!(count = attachments.len(), "Attachments loaded.");
×
409

410
        let thread = build_thread(stream, attachments, &cfg.assistant, !tools.is_empty())?;
×
411
        let root = ctx.workspace.root().to_path_buf();
×
412

413
        // Sanitize any structural issues (orphaned tool calls, missing
414
        // user messages, etc.) before sending the stream to the provider.
415
        lock.as_mut().update_events(ConversationStream::sanitize);
×
416

417
        // If a schema is provided, set it on the ChatRequest so the
418
        // provider uses its native structured output API.
419
        let mut chat_request = chat_request;
×
420
        if let Some(schema) = &self.schema {
×
421
            chat_request.schema = schema.as_object().cloned();
×
422
        }
×
423

424
        // Stamp the request with the configured user name so transcripts
425
        // attribute each turn correctly even when teammates with different
426
        // local configs continue the conversation. `None` falls back to a
427
        // generic label at render time.
428
        chat_request.author = cfg.user.name.clone();
×
429

430
        // If the query was composed in an editor, the user has lost sight
431
        // of what they wrote by the time the editor closes. Echo it back
432
        // through the same role-aware rendering machinery used by replay
433
        // and live streaming — a labeled user header followed by the
434
        // request body — so the boundary between user input and the
435
        // forthcoming assistant response is visually clear.
436
        if query_file.is_some() {
×
437
            let mut echo = TurnView::new(
×
438
                ctx.printer.clone(),
×
439
                cfg.style.clone(),
×
440
                cfg.assistant.name.clone(),
×
441
                Some(cfg.assistant.model.id.resolved().to_string()),
×
442
            );
×
443
            echo.render_user_request(&chat_request);
×
444
        }
×
445

446
        let turn_result = self
×
447
            .handle_turn(
×
448
                &cfg,
×
449
                &ctx.signals.receiver,
×
450
                &ctx.mcp_client,
×
451
                root,
×
452
                ctx.term.is_tty,
×
453
                &thread.attachments,
×
454
                &lock,
×
455
                cfg.assistant.tool_choice.clone(),
×
456
                &tools,
×
457
                ctx.printer.clone(),
×
458
                chat_request,
×
459
            )
×
460
            .await
×
461
            .map_err(|error| cmd::Error::from(error).with_persistence(true));
×
462

463
        // Extract structured data from the conversation after the turn.
464
        if self.schema.is_some() && turn_result.is_ok() {
×
465
            let data = lock.events().iter().rev().find_map(|e| {
×
466
                e.as_chat_response()
×
467
                    .and_then(ChatResponse::as_structured_data)
×
468
                    .cloned()
×
469
            });
×
470

471
            match data {
×
472
                Some(data) => print_json(&ctx.printer, &data),
×
473
                None => return Err(Error::MissingStructuredData.into()),
×
474
            }
475
        }
×
476

477
        // Clean up the query file, unless we got an error.
478
        if let Some(path) = query_file
×
479
            && turn_result.is_ok()
×
480
        {
481
            fs::remove_file(path)?;
×
482
        }
×
483

484
        turn_result
×
485
    }
2✔
486

487
    /// Declare what conversations this command needs.
488
    pub(crate) fn conversation_load_request(&self) -> ConversationLoadRequest {
2✔
489
        if self.is_new() {
2✔
490
            return ConversationLoadRequest::none();
×
491
        }
2✔
492

493
        ConversationLoadRequest::explicit_or_session_with_config(&self.target)
2✔
494
    }
2✔
495

496
    /// Build the chat request for this query.
497
    ///
498
    /// Returns the editor details and the [`ChatRequest`], if non-empty.
499
    /// The request is **not** added to the stream — that is the
500
    /// responsibility of [`TurnCoordinator::start_turn`].
501
    ///
502
    /// [`TurnCoordinator::start_turn`]: turn::TurnCoordinator::start_turn
503
    fn build_conversation(
2✔
504
        &self,
2✔
505
        stream: &mut ConversationStream,
2✔
506
        config: &AppConfig,
2✔
507
        conversation_root: &Utf8Path,
2✔
508
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig, Option<ChatRequest>)> {
2✔
509
        // If replaying, remove all events up-to-and-including the last
510
        // `ChatRequest` event, which we'll replay.
511
        //
512
        // If not replaying (or replaying but no chat request event exists), we
513
        // create a new `ChatRequest` event, to populate with either the
514
        // provided query, or the contents of the text editor.
515
        let mut chat_request = self
2✔
516
            .replay
2✔
517
            .then(|| stream.trim_chat_request())
2✔
518
            .flatten()
2✔
519
            .unwrap_or_default();
2✔
520

521
        // If stdin contains data, we prepend it to the chat request.
522
        let stdin = io::stdin();
2✔
523
        let piped = if stdin.is_terminal() {
2✔
524
            String::new()
×
525
        } else {
526
            stdin
2✔
527
                .lock()
2✔
528
                .lines()
2✔
529
                .map_while(std::result::Result::ok)
2✔
530
                .collect::<String>()
2✔
531
        };
532

533
        if !piped.is_empty() {
2✔
534
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
535
            *chat_request = format!("{piped}{sep}{chat_request}");
×
536
        }
2✔
537

538
        // If a query is provided, prepend it to the chat request. This is only
539
        // relevant for replays, otherwise the chat request is still empty, so
540
        // we replace it with the provided query.
541
        if let Some(text) = &self.query {
2✔
542
            let text = text.join(" ");
×
543
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
544
            *chat_request = format!("{text}{sep}{chat_request}");
×
545
        }
2✔
546

547
        let (query_file, editor_provided_config) = self.edit_message(
2✔
548
            &mut chat_request,
2✔
549
            stream,
2✔
550
            !piped.is_empty(),
2✔
551
            config,
2✔
552
            conversation_root,
2✔
553
        )?;
2✔
554

555
        if self.template {
×
556
            let mut env = Environment::empty();
×
557
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
558
            env.add_template("query", &chat_request.content)?;
×
559

560
            let tmpl = env.get_template("query")?;
×
561
            // TODO: supported nested variables
562
            for var in tmpl.undeclared_variables(false) {
×
563
                if config.template.values.contains_key(&var) {
×
564
                    continue;
×
565
                }
×
566

567
                return Err(Error::TemplateUndefinedVariable(var));
×
568
            }
569

570
            *chat_request = tmpl.render(&config.template.values)?;
×
571
        }
×
572

573
        Ok((
×
574
            query_file,
×
575
            editor_provided_config,
×
576
            (!chat_request.is_empty()).then_some(chat_request),
×
577
        ))
×
578
    }
2✔
579

580
    /// Create a new conversation and return an exclusive lock.
581
    fn create_new_conversation(&self, ctx: &mut Ctx) -> Result<ConversationLock> {
×
582
        let cfg = ctx.config();
×
583
        let ws = &mut ctx.workspace;
×
584

585
        let conversation = Conversation::default().with_local(self.is_local(&cfg.conversation));
×
586
        let lock =
×
587
            ws.create_and_lock_conversation(conversation, cfg.clone(), ctx.session.as_ref())?;
×
588
        let id = lock.id();
×
589

590
        if let Some(duration) = self.expires_in_duration() {
×
591
            let mut conv = lock.as_mut();
×
592
            conv.update_metadata(|m| {
×
593
                m.expires_at = chrono::Duration::from_std(duration)
×
594
                    .ok()
×
595
                    .and_then(|v| id.timestamp().checked_add_signed(v));
×
596
            });
×
597
            conv.flush()?;
×
598
        }
×
599

600
        debug!(
×
601
            id = id.to_string(),
×
602
            local = self.is_local(&cfg.conversation),
×
603
            expires_in = self.expires_in_duration().map_or_else(
×
604
                || "when inactive".to_owned(),
×
605
                |v| humantime::format_duration(v).to_string()
×
606
            ),
607
            "Creating new conversation."
608
        );
609

610
        Ok(lock)
×
611
    }
×
612

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

642
        // If a query is provided, and editing is not explicitly requested, or
643
        // in addition to the query, stdin contains data, we omit opening the
644
        // editor.
645
        if (self.query.as_ref().is_some_and(|v| !v.is_empty()) || !piped)
2✔
646
            && !self.force_edit()
2✔
647
            && !request.is_empty()
2✔
648
        {
649
            return Ok((None, PartialAppConfig::empty()));
×
650
        }
2✔
651

652
        let editor = match config.editor.command() {
2✔
653
            None if !request.is_empty() => return Ok((None, PartialAppConfig::empty())),
2✔
654
            None => return Err(Error::MissingEditor),
2✔
655
            Some(cmd) => cmd,
×
656
        };
657

658
        let (content, query_file, editor_provided_config) = editor::edit_query(
×
659
            config,
×
660
            conversation_root,
×
661
            stream,
×
662
            request.as_str(),
×
663
            editor,
×
664
            None,
×
665
        )?;
×
666
        request.content = content;
×
667

668
        Ok((Some(query_file), editor_provided_config))
×
669
    }
2✔
670

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

696
        // Build docs map from the resolved definitions for describe_tools.
697
        let docs_map: IndexMap<String, ToolDocs> = tools
×
698
            .iter()
×
699
            .map(|t| (t.name.clone(), t.docs.clone()))
×
700
            .collect();
×
701
        let builtin_executors =
×
702
            BuiltinExecutors::new().register("describe_tools", DescribeTools::new(docs_map));
×
703
        let executor_source = TerminalExecutorSource::new(builtin_executors, tools);
×
704
        let tool_coordinator =
×
705
            ToolCoordinator::new(cfg.conversation.tools.clone(), Box::new(executor_source));
×
706
        let prompt_backend = Arc::new(TerminalPromptBackend);
×
707

708
        run_turn_loop(
×
709
            provider,
×
710
            &model,
×
711
            cfg,
×
712
            signals,
×
713
            mcp_client,
×
714
            &root,
×
715
            is_tty,
×
716
            attachments,
×
717
            lock,
×
718
            tool_choice,
×
719
            tools,
×
720
            printer,
×
721
            prompt_backend,
×
722
            tool_coordinator,
×
723
            chat_request,
×
724
        )
×
725
        .await
×
726
    }
×
727

728
    /// Returns `true` if editing is explicitly disabled.
729
    ///
730
    /// This signals that even if no query is provided, no editor should be
731
    /// opened, but instead an empty query should be used.
732
    ///
733
    /// This can be used for example when requesting a tool call without needing
734
    /// additional context to be provided.
735
    fn force_no_edit(&self) -> bool {
4✔
736
        self.no_edit || matches!(self.edit, Some(Some(Editor::Disabled)))
4✔
737
    }
4✔
738

739
    /// Returns `true` if editing is explicitly enabled.
740
    ///
741
    /// This means the `--edit` flag was provided (but not `--edit=false`),
742
    /// which means the editor should be opened, regardless of whether a query
743
    /// is provided as an argument.
744
    fn force_edit(&self) -> bool {
2✔
745
        !self.force_no_edit() && self.edit.is_some()
2✔
746
    }
2✔
747

748
    #[must_use]
749
    fn is_local(&self, cfg: &ConversationConfig) -> bool {
×
750
        (self.local || cfg.start_local) && !self.no_local
×
751
    }
×
752

753
    #[must_use]
754
    fn is_new(&self) -> bool {
4✔
755
        self.new_conversation
4✔
756
    }
4✔
757

758
    #[must_use]
759
    fn expires_in_duration(&self) -> Option<Duration> {
×
760
        self.expires_in?
×
761
            .map(Duration::from)
×
762
            .or_else(|| Some(Duration::new(0, 0)))
×
763
    }
×
764

765
    async fn acquire_lock(
2✔
766
        &self,
2✔
767
        ctx: &mut Ctx,
2✔
768
        handle: Option<ConversationHandle>,
2✔
769
    ) -> Result<ConversationLock> {
2✔
770
        // Handle --new: create a fresh conversation.
771
        if self.is_new() {
2✔
772
            return self.create_new_conversation(ctx);
×
773
        }
2✔
774

775
        let handle = handle.ok_or(Error::NoConversationTarget)?;
2✔
776

777
        // Handle --fork: fork the conversation before locking.
778
        if let Some(fork_turns) = &self.fork {
2✔
779
            return fork_conversation(ctx, &handle, *fork_turns);
×
780
        }
2✔
781

782
        let req = LockRequest::from_ctx(handle, ctx)
2✔
783
            .allow_new(true)
2✔
784
            .allow_fork(true);
2✔
785

786
        match acquire_lock(req).await? {
2✔
787
            LockOutcome::Acquired(lock) => Ok(lock),
2✔
788
            LockOutcome::NewConversation => self.create_new_conversation(ctx),
×
789
            LockOutcome::ForkConversation(handle) => fork_conversation(ctx, &handle, None),
×
790
        }
791
    }
2✔
792
}
793

794
/// A single tool selection directive from the CLI.
795
///
796
/// Directives are evaluated left-to-right, allowing users to compose tool sets
797
/// precisely (e.g. `--no-tools --tool=write --no-tools=fs_modify_file`).
798
#[derive(Debug, Clone, PartialEq, Eq)]
799
enum ToolDirective {
800
    EnableAll,
801
    DisableAll,
802
    Enable(String),
803
    Disable(String),
804
}
805

806
impl ToolDirective {
807
    /// Returns the single-tool directive as a string slice.
808
    #[must_use]
809
    fn as_single(&self) -> Option<&str> {
19✔
810
        match self {
19✔
811
            Self::Enable(name) | Self::Disable(name) => Some(name.as_str()),
10✔
812
            _ => None,
9✔
813
        }
814
    }
19✔
815
}
816

817
/// Ordered sequence of tool directives parsed from `--tool` and `--no-tools`.
818
///
819
/// Implements manual [`clap::Args`] and [`clap::FromArgMatches`] to recover the
820
/// position of each flag value using [`ArgMatches::indices_of`], then merges
821
/// and sorts them by index into a single ordered list.
822
///
823
/// [`ArgMatches::indices_of`]: clap::ArgMatches::indices_of
824
#[derive(Debug, Clone, Default)]
825
struct ToolDirectives(Vec<ToolDirective>);
826

827
impl std::ops::Deref for ToolDirectives {
828
    type Target = [ToolDirective];
829

830
    fn deref(&self) -> &Self::Target {
59✔
831
        &self.0
59✔
832
    }
59✔
833
}
834

835
impl clap::FromArgMatches for ToolDirectives {
836
    fn from_arg_matches(matches: &clap::ArgMatches) -> std::result::Result<Self, clap::Error> {
2✔
837
        let tool_values: Vec<String> = matches
2✔
838
            .get_many("tools")
2✔
839
            .map(|v| v.cloned().collect())
2✔
840
            .unwrap_or_default();
2✔
841
        let tool_indices: Vec<_> = matches
2✔
842
            .indices_of("tools")
2✔
843
            .map(Iterator::collect)
2✔
844
            .unwrap_or_default();
2✔
845

846
        let no_tool_values: Vec<String> = matches
2✔
847
            .get_many("no_tools")
2✔
848
            .map(|v| v.cloned().collect())
2✔
849
            .unwrap_or_default();
2✔
850
        let no_tool_indices: Vec<_> = matches
2✔
851
            .indices_of("no_tools")
2✔
852
            .map(Iterator::collect)
2✔
853
            .unwrap_or_default();
2✔
854

855
        let mut indexed = vec![];
2✔
856
        for (val, idx) in tool_values.into_iter().zip(tool_indices) {
2✔
857
            let directive = if val.is_empty() {
×
858
                ToolDirective::EnableAll
×
859
            } else {
860
                ToolDirective::Enable(val)
×
861
            };
862
            indexed.push((idx, directive));
×
863
        }
864

865
        for (val, idx) in no_tool_values.into_iter().zip(no_tool_indices) {
2✔
866
            let directive = if val.is_empty() {
×
867
                ToolDirective::DisableAll
×
868
            } else {
869
                ToolDirective::Disable(val)
×
870
            };
871
            indexed.push((idx, directive));
×
872
        }
873

874
        indexed.sort_by_key(|(idx, _)| *idx);
2✔
875
        Ok(Self(indexed.into_iter().map(|(_, d)| d).collect()))
2✔
876
    }
2✔
877

878
    fn update_from_arg_matches(
×
879
        &mut self,
×
880
        matches: &clap::ArgMatches,
×
881
    ) -> std::result::Result<(), clap::Error> {
×
882
        *self = Self::from_arg_matches(matches)?;
×
883
        Ok(())
×
884
    }
×
885
}
886

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

924
    fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
×
925
        Self::augment_args(cmd)
×
926
    }
×
927
}
928

929
/// Fork a conversation and return the new conversation's lock.
930
fn fork_conversation(
×
931
    ctx: &mut Ctx,
×
932
    source: &ConversationHandle,
×
933
    fork_turns: Option<usize>,
×
934
) -> Result<ConversationLock> {
×
935
    fork::fork_conversation(ctx, source, |events| {
×
936
        if let Some(n) = fork_turns {
×
937
            events.retain_last_turns(n);
×
938
        }
×
939
    })
×
940
}
×
941

942
/// Apply `--title` / `--no-title` to the resolved conversation.
943
///
944
/// Both flags act on `metadata.title` directly so the run ends with the
945
/// title the user asked for, regardless of whether the conversation is new,
946
/// freshly forked (which inherits the source's title), or resumed:
947
///
948
/// - `--title T` sets the title to `Some(T)`.
949
/// - `--no-title` clears any existing title.
950
/// - Neither flag is a no-op.
951
fn apply_title_override(lock: &ConversationLock, title: Option<&str>, no_title: bool) {
6✔
952
    if let Some(title) = title {
6✔
953
        lock.as_mut().update_metadata(|m| {
1✔
954
            m.title = Some(title.to_owned());
1✔
955
        });
1✔
956
    } else if no_title {
5✔
957
        lock.as_mut().update_metadata(|m| {
2✔
958
            m.title = None;
2✔
959
        });
2✔
960
    }
3✔
961
}
6✔
962

963
fn get_config_delta_from_cli(
4✔
964
    cfg: &AppConfig,
4✔
965
    lock: &ConversationLock,
4✔
966
) -> Result<Option<PartialAppConfig>> {
4✔
967
    let partial = lock
4✔
968
        .events()
4✔
969
        .config()
4✔
970
        .map(|c| c.to_partial())
4✔
971
        .map_err(jp_conversation::Error::from)?;
4✔
972

973
    let partial = partial.delta(cfg.to_partial());
4✔
974
    if partial.is_empty() {
4✔
975
        return Ok(None);
×
976
    }
4✔
977

978
    Ok(Some(partial))
4✔
979
}
4✔
980

981
impl IntoPartialAppConfig for Query {
982
    fn apply_cli_config(
23✔
983
        &self,
23✔
984
        workspace: Option<&Workspace>,
23✔
985
        mut partial: PartialAppConfig,
23✔
986
        merged_config: Option<&PartialAppConfig>,
23✔
987
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
23✔
988
        let Self {
989
            model,
23✔
990
            template: _,
991
            schema: _,
992
            replay: _,
993
            new_conversation: _,
994
            local: _,
995
            no_local: _,
996
            attachments,
23✔
997
            edit,
23✔
998
            no_edit,
23✔
999
            tool_use,
23✔
1000
            no_tool_use,
23✔
1001
            query: _,
1002
            parameters,
23✔
1003
            hide_reasoning,
23✔
1004
            hide_tool_calls,
23✔
1005
            tool_directives,
23✔
1006
            reasoning,
23✔
1007
            no_reasoning,
23✔
1008
            expires_in: _,
1009
            target: _,
1010
            fork: _,
1011
            title: _,
1012
            no_title: _,
1013
        } = &self;
23✔
1014

1015
        apply_model(&mut partial, model.as_deref(), merged_config);
23✔
1016
        apply_editor(&mut partial, edit.as_ref().map(|v| v.as_ref()), *no_edit);
23✔
1017

1018
        // Inject builtin tool configs before tool-enable processing.
1019
        for (name, config) in tool::builtins::all() {
23✔
1020
            partial
23✔
1021
                .conversation
23✔
1022
                .tools
23✔
1023
                .tools
23✔
1024
                .entry(name)
23✔
1025
                .or_insert(config);
23✔
1026
        }
23✔
1027

1028
        apply_enable_tools(&mut partial, tool_directives, merged_config)?;
23✔
1029
        apply_tool_use(
23✔
1030
            &mut partial,
23✔
1031
            tool_use.as_ref().map(|v| v.as_deref()),
23✔
1032
            *no_tool_use,
23✔
1033
        )?;
×
1034
        apply_attachments(&mut partial, attachments, workspace)?;
23✔
1035
        apply_reasoning(&mut partial, reasoning.as_ref(), *no_reasoning);
23✔
1036

1037
        for kv in parameters.clone() {
23✔
1038
            partial.assistant.model.parameters.assign(kv)?;
×
1039
        }
1040

1041
        if *hide_reasoning {
23✔
1042
            partial.style.reasoning.display = Some(ReasoningDisplayConfig::Hidden);
×
1043
        }
23✔
1044

1045
        if *hide_tool_calls {
23✔
1046
            partial.style.tool_call.show = Some(false);
×
1047
        }
23✔
1048

1049
        Ok(partial)
23✔
1050
    }
23✔
1051

1052
    fn apply_conversation_config(
4✔
1053
        &self,
4✔
1054
        workspace: &Workspace,
4✔
1055
        partial: PartialAppConfig,
4✔
1056
        _: Option<&PartialAppConfig>,
4✔
1057
        handle: &ConversationHandle,
4✔
1058
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
4✔
1059
        let config = workspace.events(handle)?.config().map(|c| c.to_partial())?;
4✔
1060

1061
        load_partial(partial, config).map_err(Into::into)
4✔
1062
    }
4✔
1063
}
1064

1065
/// Build the sorted list of system prompt sections from assistant config.
1066
///
1067
/// Used by both [`build_thread`] and [`LlmInquiryBackend`] construction
1068
/// to ensure the inquiry backend sees the same sections as the main thread.
1069
///
1070
/// [`LlmInquiryBackend`]: crate::cmd::query::tool::inquiry::LlmInquiryBackend
1071
pub(super) fn build_sections(assistant: &AssistantConfig, has_tools: bool) -> Vec<SectionConfig> {
92✔
1072
    let mut sections: Vec<_> = assistant.system_prompt_sections.clone();
92✔
1073
    sections.extend(
92✔
1074
        assistant
92✔
1075
            .instructions
92✔
1076
            .iter()
92✔
1077
            .map(InstructionsConfig::to_section),
92✔
1078
    );
1079

1080
    if has_tools {
92✔
1081
        let tool_section = InstructionsConfig::default()
42✔
1082
            .with_title("Tool Usage")
42✔
1083
            .with_description("How to leverage the tools available to you.".to_string())
42✔
1084
            .with_item("Use all the tools available to you to give the best possible answer.")
42✔
1085
            .with_item("Verify the tool name, description and parameters are correct.")
42✔
1086
            .with_item(
42✔
1087
                "Even if you've reasoned yourself towards a solution, use any available tool to \
42✔
1088
                 verify your answer.",
42✔
1089
            )
42✔
1090
            .to_section();
42✔
1091

42✔
1092
        sections.push(tool_section);
42✔
1093
    }
50✔
1094

1095
    sections.sort_by_key(|s| s.position);
92✔
1096
    sections
92✔
1097
}
92✔
1098

1099
fn build_thread(
58✔
1100
    events: ConversationStream,
58✔
1101
    attachments: Vec<Attachment>,
58✔
1102
    assistant: &AssistantConfig,
58✔
1103
    has_tools: bool,
58✔
1104
) -> Result<Thread> {
58✔
1105
    let sections = build_sections(assistant, has_tools);
58✔
1106

1107
    let mut thread_builder = ThreadBuilder::default()
58✔
1108
        .with_sections(sections)
58✔
1109
        .with_attachments(attachments)
58✔
1110
        .with_events(events);
58✔
1111

1112
    if let Some(system_prompt) = assistant.system_prompt.clone() {
58✔
1113
        thread_builder = thread_builder.with_system_prompt(system_prompt);
58✔
1114
    }
58✔
1115

1116
    Ok(thread_builder.build()?)
58✔
1117
}
58✔
1118

1119
/// Apply the CLI model configuration to the partial configuration.
1120
fn apply_model(partial: &mut PartialAppConfig, model: Option<&str>, _: Option<&PartialAppConfig>) {
23✔
1121
    let Some(id) = model else { return };
23✔
1122

1123
    partial.assistant.model.id = id.into();
6✔
1124
}
23✔
1125

1126
/// Apply the CLI editor configuration to the partial configuration.
1127
fn apply_editor(partial: &mut PartialAppConfig, editor: Option<Option<&Editor>>, no_edit: bool) {
23✔
1128
    let Some(Some(editor)) = editor else {
×
1129
        return;
23✔
1130
    };
1131

1132
    match (no_edit, editor) {
×
1133
        (true, _) | (_, Editor::Disabled) => {
×
1134
            partial.editor.cmd = None;
×
1135
            partial.editor.envs = None;
×
1136
        }
×
1137
        (_, Editor::Default) => {}
×
1138
        (_, Editor::Command(cmd)) => partial.editor.cmd = Some(cmd.clone()),
×
1139
    }
1140
}
23✔
1141

1142
fn apply_enable_tools(
23✔
1143
    partial: &mut PartialAppConfig,
23✔
1144
    directives: &ToolDirectives,
23✔
1145
    merged_config: Option<&PartialAppConfig>,
23✔
1146
) -> BoxedResult<()> {
23✔
1147
    if directives.is_empty() {
23✔
1148
        return Ok(());
11✔
1149
    }
12✔
1150

1151
    let existing_tools = merged_config.map_or(&partial.conversation.tools.tools, |v| {
12✔
1152
        &v.conversation.tools.tools
×
1153
    });
×
1154

1155
    // Validate all named tools exist.
1156
    let missing: HashSet<_> = directives
12✔
1157
        .iter()
12✔
1158
        .filter_map(ToolDirective::as_single)
12✔
1159
        .filter(|name| !existing_tools.contains_key(*name))
12✔
1160
        .collect();
12✔
1161

1162
    if missing.len() == 1 {
12✔
1163
        return Err(ToolError::NotFound {
×
1164
            name: missing.iter().next().unwrap().to_string(),
×
1165
        }
×
1166
        .into());
×
1167
    } else if !missing.is_empty() {
12✔
1168
        return Err(ToolError::NotFoundN {
×
1169
            names: missing.into_iter().map(ToString::to_string).collect(),
×
1170
        }
×
1171
        .into());
×
1172
    }
12✔
1173

1174
    // Validate that core tools are not disabled by name.
1175
    for d in directives.iter() {
19✔
1176
        if let ToolDirective::Disable(name) = d
19✔
1177
            && let Some(tool) = partial.conversation.tools.tools.get(name.as_str())
3✔
1178
            && tool.enable.is_some_and(Enable::is_always)
3✔
1179
        {
1180
            return Err(format!("Tool '{name}' is a system tool and cannot be disabled").into());
×
1181
        }
19✔
1182
    }
1183

1184
    // Apply directives left-to-right.
1185
    for d in directives.iter() {
19✔
1186
        match d {
19✔
1187
            ToolDirective::EnableAll => {
1188
                partial
5✔
1189
                    .conversation
5✔
1190
                    .tools
5✔
1191
                    .tools
5✔
1192
                    .iter_mut()
5✔
1193
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_explicit))
25✔
1194
                    .for_each(|(_, v)| v.enable = Some(Enable::On));
21✔
1195
            }
1196
            ToolDirective::DisableAll => {
1197
                partial
4✔
1198
                    .conversation
4✔
1199
                    .tools
4✔
1200
                    .tools
4✔
1201
                    .iter_mut()
4✔
1202
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_always))
20✔
1203
                    .for_each(|(_, v)| v.enable = Some(Enable::Off));
17✔
1204
            }
1205
            ToolDirective::Enable(name) => {
7✔
1206
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
7✔
1207
                    tool.enable = Some(Enable::On);
7✔
1208
                }
7✔
1209
            }
1210
            ToolDirective::Disable(name) => {
3✔
1211
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
3✔
1212
                    tool.enable = Some(Enable::Off);
3✔
1213
                }
3✔
1214
            }
1215
        }
1216
    }
1217

1218
    Ok(())
12✔
1219
}
23✔
1220

1221
/// Apply the CLI tool use configuration to the partial configuration.
1222
///
1223
/// NOTE: This has to run *after* `apply_enable_tools` because it will return an
1224
/// error if the tool of choice is not enabled.
1225
fn apply_tool_use(
23✔
1226
    partial: &mut PartialAppConfig,
23✔
1227
    tool_choice: Option<Option<&str>>,
23✔
1228
    no_tool_choice: bool,
23✔
1229
) -> BoxedResult<()> {
23✔
1230
    if no_tool_choice || matches!(tool_choice, Some(Some("false"))) {
23✔
1231
        partial.assistant.tool_choice = Some(ToolChoice::None);
×
1232
        return Ok(());
×
1233
    }
23✔
1234

1235
    let Some(tool) = tool_choice else {
23✔
1236
        return Ok(());
23✔
1237
    };
1238

1239
    partial.assistant.tool_choice = match tool {
×
1240
        None | Some("true") => Some(ToolChoice::Required),
×
1241
        Some(v) => {
×
1242
            if !partial
×
1243
                .conversation
×
1244
                .tools
×
1245
                .tools
×
1246
                .iter()
×
1247
                .filter(|(_, cfg)| cfg.enable.is_some_and(Enable::is_on))
×
1248
                .any(|(name, _)| name == v)
×
1249
            {
1250
                return Err(format!("tool choice '{v}' does not match any enabled tools").into());
×
1251
            }
×
1252

1253
            Some(ToolChoice::Function(v.to_owned()))
×
1254
        }
1255
    };
1256

1257
    Ok(())
×
1258
}
23✔
1259

1260
/// Apply the CLI attachments to the partial configuration.
1261
fn apply_attachments(
23✔
1262
    partial: &mut PartialAppConfig,
23✔
1263
    attachments: &[AttachmentUrlOrPath],
23✔
1264
    workspace: Option<&Workspace>,
23✔
1265
) -> Result<()> {
23✔
1266
    let root = workspace.map(Workspace::root);
23✔
1267
    let attachments = attachments
23✔
1268
        .iter()
23✔
1269
        .map(|v| v.parse(root))
23✔
1270
        .collect::<Result<Vec<_>>>()?;
23✔
1271

1272
    partial
23✔
1273
        .conversation
23✔
1274
        .attachments
23✔
1275
        .extend(attachments.into_iter().map(Into::into));
23✔
1276

1277
    Ok(())
23✔
1278
}
23✔
1279

1280
/// Apply the CLI reasoning configuration to the partial configuration.
1281
fn apply_reasoning(
23✔
1282
    partial: &mut PartialAppConfig,
23✔
1283
    reasoning: Option<&ReasoningConfig>,
23✔
1284
    no_reasoning: bool,
23✔
1285
) {
23✔
1286
    if no_reasoning {
23✔
1287
        partial.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Off);
×
1288
        return;
×
1289
    }
23✔
1290

1291
    let Some(reasoning) = reasoning else {
23✔
1292
        return;
23✔
1293
    };
1294

1295
    partial.assistant.model.parameters.reasoning = Some(match reasoning {
×
1296
        ReasoningConfig::Off => PartialReasoningConfig::Off,
×
1297
        ReasoningConfig::Auto => PartialReasoningConfig::Auto,
×
1298
        ReasoningConfig::Custom(custom) => PartialCustomReasoningConfig {
×
1299
            effort: Some(custom.effort),
×
1300
            exclude: Some(custom.exclude),
×
1301
        }
×
1302
        .into(),
×
1303
    });
1304
}
23✔
1305

1306
/// Set the terminal title to show the active conversation.
1307
fn set_terminal_title(id: ConversationId, title: Option<&str>) {
×
1308
    let display = match title {
×
1309
        Some(t) => format!("{id}: {t}"),
×
1310
        None => id.to_string(),
×
1311
    };
1312
    jp_term::osc::set_title(display);
×
1313
}
×
1314

1315
/// Parse a schema string as either a concise DSL or raw JSON Schema.
1316
#[expect(clippy::needless_pass_by_value)]
1317
fn parse_schema(s: String) -> Result<schemars::Schema> {
×
1318
    crate::schema::parse_schema_dsl(&s)
×
1319
        .map_err(|e| Error::Schema(e.to_string()))?
×
1320
        .try_into()
×
1321
        .map_err(Into::into)
×
1322
}
×
1323

1324
/// Parse the `--fork` value. Empty string means "all turns", a number means
1325
/// "keep last N turns".
1326
fn parse_fork_turns(s: &str) -> std::result::Result<Option<usize>, String> {
×
1327
    if s.is_empty() {
×
1328
        return Ok(None);
×
1329
    }
×
1330
    s.parse::<usize>()
×
1331
        .map(Some)
×
1332
        .map_err(|_| format!("expected a positive integer, got '{s}'"))
×
1333
}
×
1334

1335
fn string_or_path(s: &str) -> Result<String> {
×
1336
    if let Some(s) = s
×
1337
        .strip_prefix(PATH_STRING_PREFIX)
×
1338
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
1339
    {
1340
        return fs::read_to_string(s).map_err(Into::into);
×
1341
    }
×
1342

1343
    Ok(s.to_owned())
×
1344
}
×
1345

1346
#[cfg(test)]
1347
#[path = "query_tests.rs"]
1348
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