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

dcdpr / jp / 27708733892

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

Pull #758

github

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

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

2 existing lines in 2 files now uncovered.

35567 of 53379 relevant lines covered (66.63%)

403.16 hits per line

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

43.84
/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.
6
//! It uses a 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 the
17
//!   terminal with display mode support.
18
//!
19
//! - [`StreamRetryState`]: Single source of truth for stream retry logic
20
//!   (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
//! [`ChatRenderer`]: crate::render::ChatRenderer
41
//! [`ConversationEvent`]: jp_conversation::event::ConversationEvent
42
//! [`EventBuilder`]: jp_llm::event_builder::EventBuilder
43
//! [`InterruptHandler`]: interrupt::handler::InterruptHandler
44
//! [`StreamRetryState`]: stream::retry::StreamRetryState
45
//! [`ToolCallRequest`]: jp_conversation::event::ToolCallRequest
46
//! [`ToolCallResponse`]: jp_conversation::event::ToolCallResponse
47
//! [`TurnCoordinator`]: turn::coordinator::TurnCoordinator
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::{
75
        ConversationConfig,
76
        tool::{
77
            Enable, ToolSource,
78
            access::{AccessConfig, PartialAccessConfig, PartialFsRuleConfig},
79
        },
80
    },
81
    fs::{expand_tilde, load_partial},
82
    model::parameters::{PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig},
83
    style::reasoning::ReasoningDisplayConfig,
84
};
85
use jp_conversation::{
86
    Conversation, ConversationEvent, ConversationId, ConversationStream,
87
    event::{ChatRequest, ChatResponse},
88
    thread::{Thread, ThreadBuilder},
89
};
90
use jp_inquire::prompt::TerminalPromptBackend;
91
use jp_llm::{
92
    ToolError, provider,
93
    tool::{
94
        ToolDefinition, ToolDocs,
95
        builtin::{BuiltinExecutors, describe_tools::DescribeTools},
96
        tool_definitions,
97
    },
98
};
99
use jp_printer::Printer;
100
use jp_task::task::TitleGeneratorTask;
101
use jp_workspace::{ConversationHandle, ConversationLock, Workspace};
102
use minijinja::{Environment, UndefinedBehavior};
103
use tool::{TerminalExecutorSource, ToolCoordinator};
104
use tracing::{debug, trace, warn};
105
use turn_loop::run_turn_loop;
106

107
use super::{
108
    ConversationLoadRequest, Output, attachment::load_conversation_attachments,
109
    conversation_id::FlagIds, lock::LockOutcome,
110
};
111
use crate::{
112
    Ctx, PATH_STRING_PREFIX,
113
    access::{
114
        approvals::{APPROVALS_FILE, ApprovalLookup, ApprovalStore},
115
        compile::{ApprovalDecision, compile_policy},
116
        mount::{MountMode, MountSpec},
117
    },
118
    cmd::{
119
        self,
120
        conversation::fork,
121
        lock::{LockRequest, acquire_lock},
122
    },
123
    ctx::IntoPartialAppConfig,
124
    editor::{self, Editor},
125
    error::{Error, Result},
126
    output::print_json,
127
    parser::AttachmentUrlOrPath,
128
    render::TurnView,
129
    signals::SignalRx,
130
};
131

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

134
#[derive(Debug, Default, clap::Args)]
135
pub(crate) struct Query {
136
    /// The query to send.
137
    /// If not provided, uses `$JP_EDITOR`, `$VISUAL` or `$EDITOR` to open edit
138
    /// the query in an editor.
139
    #[arg(value_parser = string_or_path)]
140
    query: Option<Vec<String>>,
141

142
    /// Use the query string as a Jinja2 template.
143
    ///
144
    /// You can provide values for template variables using the
145
    /// `template.values` config key.
146
    #[arg(short = '%', long)]
147
    template: bool,
148

149
    /// Constrain the assistant's response to match a JSON schema.
150
    ///
151
    /// Accepts either a full JSON Schema object or a concise DSL:
152
    ///
153
    /// \-s 'summary' → single string field -s 'name, age int, bio' → mixed
154
    /// types -s 'summary: a brief summary' → field with description
155
    ///
156
    /// See: <https://jp.computer/rfd/030-schema-dsl>
157
    #[arg(short = 's', long, value_parser = string_or_path.try_map(parse_schema))]
158
    schema: Option<schemars::Schema>,
159

160
    /// Replay the last message in the conversation.
161
    ///
162
    /// If a query is provided, it will be appended to the end of the previous
163
    /// message.
164
    /// If no query is provided, $EDITOR will open with the last message in the
165
    /// conversation.
166
    #[arg(long = "replay", conflicts_with = "new")]
167
    replay: bool,
168

169
    #[command(flatten)]
170
    target: FlagIds<false, false>,
171

172
    /// Fork the session's active conversation (or the one specified by --id)
173
    /// and start a new turn on the fork.
174
    ///
175
    /// If N is given, the fork keeps only the last N turns.
176
    #[arg(
177
        long = "fork",
178
        num_args = 0..=1,
179
        default_missing_value = "",
180
        value_parser = parse_fork_turns,
181
        conflicts_with = "new",
182
    )]
183
    fork: Option<Option<usize>>,
184

185
    /// Start a new conversation without any message history.
186
    #[arg(short = 'n', long = "new", group = "new", conflicts_with = "id")]
187
    new_conversation: bool,
188

189
    /// Store the conversation locally, outside of the workspace.
190
    #[arg(
191
        short = 'l',
192
        long = "local",
193
        requires = "new_conversation",
194
        conflicts_with = "no_local"
195
    )]
196
    local: bool,
197

198
    /// Store the conversation in the current workspace.
199
    #[arg(
200
        short = 'L',
201
        long = "no-local",
202
        requires = "new_conversation",
203
        conflicts_with = "local"
204
    )]
205
    no_local: bool,
206

207
    /// Add attachment to the configuration.
208
    #[arg(short = 'a', long = "attachment", alias = "attach")]
209
    attachments: Vec<AttachmentUrlOrPath>,
210

211
    /// Whether and how to edit the query.
212
    ///
213
    /// Setting this flag to `true`, omitting it, or using it as a boolean flag
214
    /// (e.g.
215
    /// `--edit`) will use the default editor configured elsewhere, or return an
216
    /// error if no editor is configured and one is required.
217
    ///
218
    /// If set to `false`, the editor will be disabled (similar to `--no-edit`),
219
    /// which might result in an error if the editor is required.
220
    ///
221
    /// If set to any other value, it will be used as the command to open the
222
    /// editor.
223
    #[arg(short = 'e', long = "edit", conflicts_with = "no_edit")]
224
    edit: Option<Option<Editor>>,
225

226
    /// Do not edit the query.
227
    ///
228
    /// See `--edit` for more details.
229
    #[arg(short = 'E', long = "no-edit", conflicts_with = "edit")]
230
    no_edit: bool,
231

232
    /// Pre-fill the editor with the last assistant message quoted as a markdown
233
    /// blockquote (each line prefixed with ` >  `).
234
    ///
235
    /// Useful for inline replies: open `$EDITOR` with the assistant's last
236
    /// response pre-quoted, then intersperse your replies between the quoted
237
    /// lines (mutt/email style).
238
    /// The complete buffer — quotes plus your replies — becomes your next
239
    /// message.
240
    ///
241
    /// Forces the editor open by default; respects `--no-edit` / `--edit=false`
242
    /// if explicitly suppressed, in which case the quoted text is sent as-is.
243
    /// Composes with `--replay`: the quote is taken from the stream *after* the
244
    /// replayed turn has been trimmed, i.e. the assistant message preceding the
245
    /// turn being replayed.
246
    ///
247
    /// If no prior assistant message exists in this conversation, a warning is
248
    /// emitted and the editor opens with whatever other content was seeded
249
    /// (query, stdin, or empty).
250
    #[arg(long = "quote")]
251
    quote: bool,
252

253
    /// The model to use.
254
    #[arg(short = 'm', long = "model")]
255
    model: Option<String>,
256

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

261
    /// Enable reasoning.
262
    #[arg(short = 'r', long = "reasoning")]
263
    reasoning: Option<ReasoningConfig>,
264

265
    /// Disable reasoning.
266
    #[arg(short = 'R', long = "no-reasoning")]
267
    no_reasoning: bool,
268

269
    /// Do not display the reasoning content.
270
    ///
271
    /// This does not stop the assistant from generating reasoning tokens to
272
    /// help with its accuracy, but it does not display them in the output.
273
    #[arg(long = "hide-reasoning")]
274
    hide_reasoning: bool,
275

276
    /// Do not display tool calls.
277
    ///
278
    /// This does not stop the assistant from running tool calls, but it does
279
    /// not display them in the output.
280
    #[arg(long = "hide-tool-calls")]
281
    hide_tool_calls: bool,
282

283
    #[command(flatten)]
284
    tool_directives: ToolDirectives,
285

286
    /// Set the expiration date of the conversation.
287
    ///
288
    /// The conversation is persisted, but only until the conversation is no
289
    /// longer marked as active (e.g. when a new conversation is started), and
290
    /// when the expiration date is reached.
291
    ///
292
    /// This differs from `--no-persist` in that the conversation can contain
293
    /// multiple turns, as long as it remains active and not expired.
294
    #[arg(long = "tmp", requires = "new")]
295
    expires_in: Option<Option<humantime::Duration>>,
296

297
    /// Set a custom title for the conversation.
298
    ///
299
    /// Applied to the resolved conversation (new, forked, or resumed) before
300
    /// the turn runs.
301
    /// Skips title auto-generation for new conversations — your title wins.
302
    /// Mutually exclusive with `--no-title`.
303
    #[arg(long = "title", conflicts_with = "no_title")]
304
    title: Option<String>,
305

306
    /// Disable the title for the conversation.
307
    ///
308
    /// Clears any existing title on the resolved conversation (new, forked, or
309
    /// resumed) and skips auto-generation for this run.
310
    /// Mutually exclusive with `--title`.
311
    #[arg(long = "no-title", conflicts_with = "title")]
312
    no_title: bool,
313

314
    /// The tool to use.
315
    ///
316
    /// If a value is provided, the tool matching the value will be used.
317
    ///
318
    /// Note that this setting is *not* persisted across queries.
319
    /// To persist tool choice behavior, set the `assistant.tool_choice` field
320
    /// in a configuration file.
321
    #[arg(short = 'u', long = "tool-use")]
322
    tool_use: Option<Option<String>>,
323

324
    /// Disable tool use by the assistant.
325
    #[arg(short = 'U', long = "no-tool-use")]
326
    no_tool_use: bool,
327

328
    /// Compact the conversation before querying.
329
    #[command(flatten)]
330
    compact: crate::cmd::compact_flag::CompactFlag,
331

332
    /// Mount an external path into the workspace as a symlink and grant the
333
    /// assistant access to it.
334
    ///
335
    /// Form: `[TOOL:]NAME=PATH[:MODE]`.
336
    /// `NAME` is the workspace-relative location for the symlink, `PATH` is the
337
    /// external target, and `MODE` is `ro` (default) or `rw`.
338
    /// `rw` requires a `TOOL:` prefix; without a `TOOL:` prefix the grant
339
    /// applies to all enabled local tools.
340
    /// Repeat the flag to mount several paths.
341
    #[arg(long = "mount", value_name = "[TOOL:]NAME=PATH[:MODE]", action = ArgAction::Append)]
342
    mount: Vec<String>,
343
}
344

345
impl Query {
346
    #[expect(clippy::too_many_lines)]
347
    pub(crate) async fn run(self, ctx: &mut Ctx, handle: Option<ConversationHandle>) -> Output {
2✔
348
        debug!("Running `query` command.");
2✔
349
        trace!(args = ?self, "Received arguments.");
2✔
350
        let now = ctx.now();
2✔
351
        let cfg = ctx.config();
2✔
352

353
        // Resolve the target conversation and acquire an exclusive lock.
354
        //
355
        // Three paths:
356
        // 1. --new: create a fresh conversation (already locked).
357
        // 2. --fork/--id/session: resolve an existing conversation, lock it.
358
        // 3. Lock contention: user picks "new" or "fork" from the prompt.
359
        let lock = self.acquire_lock(ctx, handle).await?;
2✔
360

361
        // Create symlinks and seed approvals for any `--mount` flags before the
362
        // turn runs, so tools can reach the mounted paths.
363
        create_mount_effects(&self.mount, &ctx.workspace, ctx.fs_backend.as_deref(), now)?;
2✔
364

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

370
        // Record this conversation as the session's active conversation.
371
        if let Some(session) = &ctx.session
2✔
372
            && let Err(error) = ctx
2✔
373
                .workspace
2✔
374
                .activate_session_conversation(&lock, session, now)
2✔
375
        {
376
            warn!(%error, "Failed to record activation.");
×
377
        }
2✔
378

379
        if let Some(delta) = get_config_delta_from_cli(&cfg, &lock)? {
2✔
380
            lock.as_mut()
2✔
381
                .update_events(|events| events.add_config_delta(delta));
2✔
382
        }
×
383

384
        // Compact the conversation before querying, if requested.
385
        if self.compact.should_compact() {
2✔
386
            self.apply_pre_query_compaction(&lock, &cfg, ctx).await?;
×
387
        }
2✔
388

389
        let mut mcp_servers_handle = ctx.configure_active_mcp_servers().await?;
2✔
390

391
        let (conv_title, is_local) = {
2✔
392
            let m = lock.metadata();
2✔
393
            (m.title.clone(), m.user)
2✔
394
        };
2✔
395

396
        // Show conversation identity in the terminal title.
397
        if ctx.term.is_tty {
2✔
398
            set_terminal_title(lock.id(), conv_title.as_deref());
×
399
        }
2✔
400

401
        let cid = lock.id();
2✔
402
        let conversation_path = ctx.fs_backend.as_deref().map_or_else(
2✔
403
            || {
×
404
                ctx.workspace
×
405
                    .root()
×
406
                    .join(cid.to_dirname(conv_title.as_deref()))
×
407
            },
×
408
            |fs| fs.build_conversation_dir(&cid, conv_title.as_deref(), is_local),
2✔
409
        );
410

411
        let (query_file, mut editor_provided_config, chat_request) = lock
2✔
412
            .as_mut()
2✔
413
            .update_events(|stream| self.build_conversation(stream, &cfg, &conversation_path))?;
2✔
414

415
        let Some(mut chat_request) = chat_request else {
×
416
            // Empty query, early exit. Auto-persist happens on lock drop.
417
            if let Some(path) = query_file.as_deref() {
×
418
                fs::remove_file(path)?;
×
419
            }
×
420
            ctx.printer.println("Query is empty, ignoring.");
×
421
            return Ok(());
×
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 a schema is provided, set it on the ChatRequest so the
431
        // provider uses its native structured output API.
432
        if let Some(schema) = &self.schema {
×
433
            chat_request.schema = schema.as_object().cloned();
×
434
        }
×
435

436
        // If the query was composed in an editor, the user has lost sight
437
        // of what they wrote by the time the editor closes. Echo it back
438
        // through the same role-aware rendering machinery used by replay
439
        // and live streaming — a labeled user header followed by the
440
        // request body — so the boundary between user input and the
441
        // forthcoming assistant response is visually clear. Render this
442
        // before any post-edit work (MCP init, attachments, tools) so that
443
        // failures in those stages don't swallow the user's message.
444
        if query_file.is_some() {
×
445
            let mut echo = TurnView::new(
×
446
                ctx.printer.clone(),
×
447
                cfg.style.clone(),
×
448
                cfg.assistant.name.clone(),
×
449
                Some(cfg.assistant.model.id.resolved().to_string()),
×
450
            );
×
451
            echo.render_user_request(&chat_request);
×
452
        }
×
453

454
        if !editor_provided_config.is_empty() {
×
455
            // Resolve any model aliases before storing in the stream so
456
            // that per-event configs always contain concrete model IDs.
457
            editor_provided_config.resolve_model_aliases(&cfg.providers.llm.aliases);
×
458
            lock.as_mut()
×
459
                .update_events(|events| events.add_config_delta(editor_provided_config));
×
460
        }
×
461

462
        let stream = lock.events().clone();
×
463

464
        // Set the title for new or empty conversations (including forks).
465
        // Skip when `--title` or `--no-title` was provided (the user already
466
        // expressed an intent for the title).
467
        //
468
        // A leading markdown heading in the prompt is used verbatim as the
469
        // title, short-circuiting the LLM round-trip. Otherwise, fall back to
470
        // background title generation when enabled.
471
        if (self.is_new() || self.fork.is_some() || stream.is_empty())
×
472
            && ctx.term.args.persist
×
473
            && self.title.is_none()
×
474
            && !self.no_title
×
475
        {
476
            match resolve_new_title(
×
477
                cfg.conversation.title.from_heading,
×
478
                cfg.conversation.title.generate.auto,
×
479
                &chat_request.content,
×
480
            ) {
×
481
                NewTitle::FromHeading(title) => {
×
482
                    debug!("Using leading markdown heading as conversation title");
×
483
                    lock.as_mut()
×
484
                        .update_metadata(|m| m.title = Some(title.clone()));
×
485
                    if ctx.term.is_tty {
×
486
                        jp_term::osc::set_title(format!("{cid}: {title}"));
×
487
                    }
×
488
                }
489
                NewTitle::Generate => {
490
                    debug!("Generating title for new conversation");
×
491
                    let mut stream = stream.clone();
×
492
                    stream.start_turn(chat_request.clone());
×
493
                    ctx.task_handler.spawn(TitleGeneratorTask::new(
×
494
                        cid,
×
495
                        stream,
×
496
                        &cfg,
×
497
                        ctx.term.is_tty,
×
498
                    )?);
×
499
                }
500
                NewTitle::Skip => {}
×
501
            }
502
        }
×
503

504
        // Wait for all MCP servers to finish loading.
505
        while let Some(result) = mcp_servers_handle.join_next().await {
×
506
            result??;
×
507
        }
508

509
        let forced_tool = cfg.assistant.tool_choice.function_name();
×
510
        let tools =
×
511
            tool_definitions(cfg.conversation.tools.iter(), &ctx.mcp_client, forced_tool).await?;
×
512

513
        let attachment_urls: Vec<_> = cfg
×
514
            .conversation
×
515
            .attachments
×
516
            .iter()
×
517
            .map(jp_config::conversation::attachment::AttachmentConfig::to_url)
×
518
            .collect::<std::result::Result<Vec<_>, _>>()?;
×
519
        let attachments = load_conversation_attachments(ctx, attachment_urls).await?;
×
520

521
        debug!(count = attachments.len(), "Attachments loaded.");
×
522

523
        let thread = build_thread(stream, attachments, &cfg.assistant, !tools.is_empty())?;
×
524
        let root = ctx.workspace.root().to_path_buf();
×
525
        let approvals = Arc::new(load_approval_store(ctx.fs_backend.as_deref()));
×
526

527
        // Sanitize any structural issues (orphaned tool calls, missing
528
        // user messages, etc.) before sending the stream to the provider.
529
        lock.as_mut().update_events(ConversationStream::sanitize);
×
530

531
        let turn_result = self
×
532
            .handle_turn(
×
533
                &cfg,
×
534
                &ctx.signals.receiver,
×
535
                &ctx.mcp_client,
×
536
                root,
×
537
                ctx.term.is_tty,
×
538
                &thread.attachments,
×
539
                &lock,
×
540
                cfg.assistant.tool_choice.clone(),
×
541
                &tools,
×
542
                ctx.printer.clone(),
×
543
                approvals,
×
544
                chat_request,
×
545
            )
×
546
            .await
×
547
            .map_err(|error| cmd::Error::from(error).with_persistence(true));
×
548

549
        // Extract structured data from the conversation after the turn.
550
        if self.schema.is_some() && turn_result.is_ok() {
×
551
            let data = lock.events().iter().rev().find_map(|e| {
×
552
                e.as_chat_response()
×
553
                    .and_then(ChatResponse::as_structured_data)
×
554
                    .cloned()
×
555
            });
×
556

557
            match data {
×
558
                Some(data) => print_json(&ctx.printer, &data),
×
559
                None => return Err(Error::MissingStructuredData.into()),
×
560
            }
561
        }
×
562

563
        // Clean up the query file, unless we got an error.
564
        if let Some(path) = query_file
×
565
            && turn_result.is_ok()
×
566
        {
567
            fs::remove_file(path)?;
×
568
        }
×
569

570
        turn_result
×
571
    }
2✔
572

573
    /// Declare what conversations this command needs.
574
    pub(crate) fn conversation_load_request(&self) -> ConversationLoadRequest {
2✔
575
        if self.is_new() {
2✔
576
            return ConversationLoadRequest::none();
×
577
        }
2✔
578

579
        ConversationLoadRequest::explicit_or_session_with_config(&self.target)
2✔
580
    }
2✔
581

582
    /// Build the chat request for this query.
583
    ///
584
    /// Returns the editor details and the [`ChatRequest`], if non-empty.
585
    /// The request is **not** added to the stream — that is the responsibility
586
    /// of [`TurnCoordinator::start_turn`].
587
    ///
588
    /// [`TurnCoordinator::start_turn`]: turn::TurnCoordinator::start_turn
589
    fn build_conversation(
2✔
590
        &self,
2✔
591
        stream: &mut ConversationStream,
2✔
592
        config: &AppConfig,
2✔
593
        conversation_root: &Utf8Path,
2✔
594
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig, Option<ChatRequest>)> {
2✔
595
        // If replaying, remove all events up-to-and-including the last
596
        // `ChatRequest` event, which we'll replay.
597
        //
598
        // If not replaying (or replaying but no chat request event exists), we
599
        // create a new `ChatRequest` event, to populate with either the
600
        // provided query, or the contents of the text editor.
601
        let mut chat_request = self
2✔
602
            .replay
2✔
603
            .then(|| stream.trim_chat_request())
2✔
604
            .flatten()
2✔
605
            .unwrap_or_default();
2✔
606

607
        // If stdin contains data, we prepend it to the chat request.
608
        let stdin = io::stdin();
2✔
609
        let piped = if stdin.is_terminal() {
2✔
610
            String::new()
×
611
        } else {
612
            stdin
2✔
613
                .lock()
2✔
614
                .lines()
2✔
615
                .map_while(std::result::Result::ok)
2✔
616
                .collect::<String>()
2✔
617
        };
618

619
        if !piped.is_empty() {
2✔
620
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
621
            *chat_request = format!("{piped}{sep}{chat_request}");
×
622
        }
2✔
623

624
        // If a query is provided, prepend it to the chat request. This is only
625
        // relevant for replays, otherwise the chat request is still empty, so
626
        // we replace it with the provided query.
627
        if let Some(text) = &self.query {
2✔
628
            let text = text.join(" ");
×
629
            let sep = if chat_request.is_empty() { "" } else { "\n\n" };
×
630
            *chat_request = format!("{text}{sep}{chat_request}");
×
631
        }
2✔
632

633
        // If --quote is set, prepend the last assistant message as a markdown
634
        // blockquote so it sits at the top of the editor buffer. The user can
635
        // then intersperse replies between the quoted lines (mutt-style inline
636
        // reply). Missing message (e.g. brand new conversation) degrades to a
637
        // warning and the editor opens with whatever else was seeded.
638
        if self.quote {
2✔
639
            if let Some(message) = last_assistant_message(stream) {
×
640
                let quoted = blockquote(message);
×
641
                *chat_request = format!("{quoted}\n\n{chat_request}");
×
642
            } else {
×
643
                warn!("--quote: no prior assistant message in this conversation");
×
644
            }
645
        }
2✔
646

647
        let (query_file, editor_provided_config) = self.edit_message(
2✔
648
            &mut chat_request,
2✔
649
            stream,
2✔
650
            !piped.is_empty(),
2✔
651
            config,
2✔
652
            conversation_root,
2✔
653
        )?;
2✔
654

655
        if self.template {
×
656
            let mut env = Environment::empty();
×
657
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
658
            env.add_template("query", &chat_request.content)?;
×
659

660
            let tmpl = env.get_template("query")?;
×
661
            // TODO: supported nested variables
662
            for var in tmpl.undeclared_variables(false) {
×
663
                if config.template.values.contains_key(&var) {
×
664
                    continue;
×
665
                }
×
666

667
                return Err(Error::TemplateUndefinedVariable(var));
×
668
            }
669

670
            *chat_request = tmpl.render(&config.template.values)?;
×
671
        }
×
672

673
        Ok((
×
674
            query_file,
×
675
            editor_provided_config,
×
676
            (!chat_request.is_empty()).then_some(chat_request),
×
677
        ))
×
678
    }
2✔
679

680
    /// Create a new conversation and return an exclusive lock.
681
    fn create_new_conversation(&self, ctx: &mut Ctx) -> Result<ConversationLock> {
×
682
        let cfg = ctx.config();
×
683
        let ws = &mut ctx.workspace;
×
684

685
        let conversation = Conversation::default().with_local(self.is_local(&cfg.conversation));
×
686
        let lock =
×
687
            ws.create_and_lock_conversation(conversation, cfg.clone(), ctx.session.as_ref())?;
×
688
        let id = lock.id();
×
689

690
        if let Some(duration) = self.expires_in_duration() {
×
691
            let mut conv = lock.as_mut();
×
692
            conv.update_metadata(|m| {
×
693
                m.expires_at = chrono::Duration::from_std(duration)
×
694
                    .ok()
×
695
                    .and_then(|v| id.timestamp().checked_add_signed(v));
×
696
            });
×
697
            conv.flush()?;
×
698
        }
×
699

700
        debug!(
×
701
            id = id.to_string(),
×
702
            local = self.is_local(&cfg.conversation),
×
703
            expires_in = self.expires_in_duration().map_or_else(
×
704
                || "when inactive".to_owned(),
×
705
                |v| humantime::format_duration(v).to_string()
×
706
            ),
707
            "Creating new conversation."
708
        );
709

710
        Ok(lock)
×
711
    }
×
712

713
    // Open the editor for the query, if requested.
714
    fn edit_message(
2✔
715
        &self,
2✔
716
        request: &mut ChatRequest,
2✔
717
        stream: &mut ConversationStream,
2✔
718
        piped: bool,
2✔
719
        config: &AppConfig,
2✔
720
        conversation_root: &Utf8Path,
2✔
721
    ) -> Result<(Option<Utf8PathBuf>, PartialAppConfig)> {
2✔
722
        // If there is no query provided, but the user explicitly requested not
723
        // to open the editor, we populate the query with a default message,
724
        // since most LLM providers do not support empty queries.
725
        //
726
        // See `force_no_edit` why this can be useful.
727
        if request.is_empty() && self.force_no_edit() {
2✔
728
            // If the last event in the stream is a `ChatRequest`, we don't add
729
            // anything, and simply "replay" the last message in the
730
            // conversation.
731
            //
732
            // Otherwise we add a default "continue" message.
733
            if let Some(last) = stream.pop_if(ConversationEvent::is_chat_request)
×
734
                && let Some(req) = last.into_inner().into_chat_request()
×
735
            {
×
736
                *request = req;
×
737
            } else {
×
738
                "continue".clone_into(request);
×
739
            }
×
740
        }
2✔
741

742
        // If a query is provided, and editing is not explicitly requested, or
743
        // in addition to the query, stdin contains data, we omit opening the
744
        // editor.
745
        if (self.query.as_ref().is_some_and(|v| !v.is_empty()) || !piped)
2✔
746
            && !self.force_edit()
2✔
747
            && !request.is_empty()
2✔
748
        {
749
            return Ok((None, PartialAppConfig::empty()));
×
750
        }
2✔
751

752
        let editor = match config.editor.command() {
2✔
753
            None if !request.is_empty() => return Ok((None, PartialAppConfig::empty())),
2✔
754
            None => return Err(Error::MissingEditor),
2✔
755
            Some(cmd) => cmd,
×
756
        };
757

758
        let (content, query_file, editor_provided_config) = editor::edit_query(
×
759
            config,
×
760
            conversation_root,
×
761
            stream,
×
762
            request.as_str(),
×
763
            editor,
×
764
            None,
×
765
        )?;
×
766
        request.content = content;
×
767

768
        Ok((Some(query_file), editor_provided_config))
×
769
    }
2✔
770

771
    /// Handle a single turn of conversation with the LLM.
772
    #[expect(clippy::too_many_arguments)]
773
    async fn handle_turn(
×
774
        &self,
×
775
        cfg: &AppConfig,
×
776
        signals: &SignalRx,
×
777
        mcp_client: &jp_mcp::Client,
×
778
        root: Utf8PathBuf,
×
779
        is_tty: bool,
×
780
        attachments: &[Attachment],
×
781
        lock: &ConversationLock,
×
782
        tool_choice: ToolChoice,
×
783
        tools: &[ToolDefinition],
×
784
        printer: Arc<Printer>,
×
785
        approvals: Arc<ApprovalStore>,
×
786
        chat_request: ChatRequest,
×
787
    ) -> Result<()> {
×
788
        let model_id = cfg.assistant.model.id.resolved();
×
789
        let provider: Arc<dyn jp_llm::Provider> = Arc::from(provider::get_provider(
×
790
            model_id.provider,
×
791
            &cfg.providers.llm,
×
792
        )?);
×
793
        debug!(model = %model_id, "Fetching model details.");
×
794
        let model = provider.model_details(&model_id.name).await?;
×
795
        debug!(model = model.name(), "Model details resolved.");
×
796

797
        // Build docs map from the resolved definitions for describe_tools.
798
        let docs_map: IndexMap<String, ToolDocs> = tools
×
799
            .iter()
×
800
            .map(|t| (t.name.clone(), t.docs.clone()))
×
801
            .collect();
×
802
        let builtin_executors =
×
803
            BuiltinExecutors::new().register("describe_tools", DescribeTools::new(docs_map));
×
804
        let executor_source = TerminalExecutorSource::new(builtin_executors, tools, approvals);
×
805
        let tool_coordinator =
×
NEW
806
            ToolCoordinator::new(cfg.conversation.tools.clone(), Box::new(executor_source))
×
NEW
807
                .with_interrupt(cfg.interrupt.tool_call.clone());
×
UNCOV
808
        let prompt_backend = Arc::new(TerminalPromptBackend);
×
809

810
        run_turn_loop(
×
811
            provider,
×
812
            &model,
×
813
            cfg,
×
814
            signals,
×
815
            mcp_client,
×
816
            &root,
×
817
            is_tty,
×
818
            attachments,
×
819
            lock,
×
820
            tool_choice,
×
821
            tools,
×
822
            printer,
×
823
            prompt_backend,
×
824
            tool_coordinator,
×
825
            chat_request,
×
826
        )
×
827
        .await
×
828
    }
×
829

830
    /// Returns `true` if editing is explicitly disabled.
831
    ///
832
    /// This signals that even if no query is provided, no editor should be
833
    /// opened, but instead an empty query should be used.
834
    ///
835
    /// This can be used for example when requesting a tool call without needing
836
    /// additional context to be provided.
837
    fn force_no_edit(&self) -> bool {
4✔
838
        self.no_edit || matches!(self.edit, Some(Some(Editor::Disabled)))
4✔
839
    }
4✔
840

841
    /// Returns `true` if editing is explicitly enabled.
842
    ///
843
    /// This means the `--edit` flag was provided (but not `--edit=false`), or
844
    /// `--quote` was provided (which implies editing).
845
    /// In either case the editor should be opened, regardless of whether a
846
    /// query is provided as an argument.
847
    fn force_edit(&self) -> bool {
2✔
848
        !self.force_no_edit() && (self.edit.is_some() || self.quote)
2✔
849
    }
2✔
850

851
    #[must_use]
852
    fn is_local(&self, cfg: &ConversationConfig) -> bool {
×
853
        (self.local || cfg.start_local) && !self.no_local
×
854
    }
×
855

856
    #[must_use]
857
    fn is_new(&self) -> bool {
4✔
858
        self.new_conversation
4✔
859
    }
4✔
860

861
    #[must_use]
862
    fn expires_in_duration(&self) -> Option<Duration> {
×
863
        self.expires_in?
×
864
            .map(Duration::from)
×
865
            .or_else(|| Some(Duration::new(0, 0)))
×
866
    }
×
867

868
    /// Apply compaction before the query turn starts.
869
    ///
870
    /// Applies all compaction rules from the resolved config and appends the
871
    /// compaction events to the conversation.
872
    async fn apply_pre_query_compaction(
×
873
        &self,
×
874
        lock: &ConversationLock,
×
875
        cfg: &AppConfig,
×
876
        ctx: &Ctx,
×
877
    ) -> Result<()> {
×
878
        let events = lock.events().clone();
×
879

880
        // The inline DSL plan never enters the config; assemble the effective
881
        // rules from the resolved config rules plus any `-k SPEC` here.
882
        let rules = self
×
883
            .compact
×
884
            .effective_rules(&cfg.conversation.compaction.rules)
×
885
            .map_err(|e| Error::Compaction(e.to_string()))?;
×
886

887
        let compactions = super::conversation::compact::build_compaction_events(
×
888
            &events,
×
889
            cfg,
×
890
            &rules,
×
891
            super::conversation::compact::Bound::Default,
×
892
            super::conversation::compact::Bound::Default,
×
893
            &ctx.printer,
×
894
        )
×
895
        .await?;
×
896

897
        super::conversation::compact::apply_compactions(&lock.as_mut(), compactions, &ctx.printer);
×
898

899
        Ok(())
×
900
    }
×
901

902
    async fn acquire_lock(
2✔
903
        &self,
2✔
904
        ctx: &mut Ctx,
2✔
905
        handle: Option<ConversationHandle>,
2✔
906
    ) -> Result<ConversationLock> {
2✔
907
        // Handle --new: create a fresh conversation.
908
        if self.is_new() {
2✔
909
            return self.create_new_conversation(ctx);
×
910
        }
2✔
911

912
        let handle = handle.ok_or(Error::NoConversationTarget)?;
2✔
913

914
        // Handle --fork: fork the conversation before locking.
915
        if let Some(fork_turns) = &self.fork {
2✔
916
            return fork_conversation(ctx, &handle, *fork_turns);
×
917
        }
2✔
918

919
        let req = LockRequest::from_ctx(handle, ctx)
2✔
920
            .allow_new(true)
2✔
921
            .allow_fork(true);
2✔
922

923
        match acquire_lock(req).await? {
2✔
924
            LockOutcome::Acquired(lock) => Ok(lock),
2✔
925
            LockOutcome::NewConversation => self.create_new_conversation(ctx),
×
926
            LockOutcome::ForkConversation(handle) => fork_conversation(ctx, &handle, None),
×
927
        }
928
    }
2✔
929
}
930

931
/// Return the most recent assistant message text in the stream.
932
///
933
/// Walks the stream in reverse and returns the first `ChatResponse::Message` it
934
/// encounters.
935
/// Reasoning, structured-data responses, and tool calls are skipped.
936
fn last_assistant_message(stream: &ConversationStream) -> Option<&str> {
4✔
937
    stream
4✔
938
        .iter()
4✔
939
        .rev()
4✔
940
        .filter_map(|e| e.event.as_chat_response())
6✔
941
        .find_map(|r| r.as_message())
4✔
942
}
4✔
943

944
/// Prefix each line of `text` with ` >  ` for use as a markdown blockquote.
945
///
946
/// Empty lines are emitted as just `>` (no trailing space) so the blockquote
947
/// stays visually continuous across paragraph breaks while avoiding
948
/// trailing-whitespace warnings in editors.
949
fn blockquote(text: &str) -> String {
5✔
950
    text.lines()
5✔
951
        .map(|line| {
11✔
952
            if line.is_empty() {
11✔
953
                ">".to_owned()
1✔
954
            } else {
955
                format!("> {line}")
10✔
956
            }
957
        })
11✔
958
        .collect::<Vec<_>>()
5✔
959
        .join("\n")
5✔
960
}
5✔
961

962
/// A single tool selection directive from the CLI.
963
///
964
/// Directives are evaluated left-to-right, allowing users to compose tool sets
965
/// precisely (e.g.
966
/// `--no-tools --tool=write --no-tools=fs_modify_file`).
967
#[derive(Debug, Clone, PartialEq, Eq)]
968
enum ToolDirective {
969
    EnableAll,
970
    DisableAll,
971
    Enable(String),
972
    Disable(String),
973
}
974

975
impl ToolDirective {
976
    /// Returns the single-tool directive as a string slice.
977
    #[must_use]
978
    fn as_single(&self) -> Option<&str> {
19✔
979
        match self {
19✔
980
            Self::Enable(name) | Self::Disable(name) => Some(name.as_str()),
10✔
981
            _ => None,
9✔
982
        }
983
    }
19✔
984
}
985

986
/// Ordered sequence of tool directives parsed from `--tool` and `--no-tools`.
987
///
988
/// Implements manual [`clap::Args`] and [`clap::FromArgMatches`] to recover the
989
/// position of each flag value using [`ArgMatches::indices_of`], then merges
990
/// and sorts them by index into a single ordered list.
991
///
992
/// [`ArgMatches::indices_of`]: clap::ArgMatches::indices_of
993
#[derive(Debug, Clone, Default)]
994
struct ToolDirectives(Vec<ToolDirective>);
995

996
impl std::ops::Deref for ToolDirectives {
997
    type Target = [ToolDirective];
998

999
    fn deref(&self) -> &Self::Target {
58✔
1000
        &self.0
58✔
1001
    }
58✔
1002
}
1003

1004
impl clap::FromArgMatches for ToolDirectives {
1005
    fn from_arg_matches(matches: &clap::ArgMatches) -> std::result::Result<Self, clap::Error> {
2✔
1006
        let tool_values: Vec<String> = matches
2✔
1007
            .get_many("tools")
2✔
1008
            .map(|v| v.cloned().collect())
2✔
1009
            .unwrap_or_default();
2✔
1010
        let tool_indices: Vec<_> = matches
2✔
1011
            .indices_of("tools")
2✔
1012
            .map(Iterator::collect)
2✔
1013
            .unwrap_or_default();
2✔
1014

1015
        let no_tool_values: Vec<String> = matches
2✔
1016
            .get_many("no_tools")
2✔
1017
            .map(|v| v.cloned().collect())
2✔
1018
            .unwrap_or_default();
2✔
1019
        let no_tool_indices: Vec<_> = matches
2✔
1020
            .indices_of("no_tools")
2✔
1021
            .map(Iterator::collect)
2✔
1022
            .unwrap_or_default();
2✔
1023

1024
        let mut indexed = vec![];
2✔
1025
        for (val, idx) in tool_values.into_iter().zip(tool_indices) {
2✔
1026
            let directive = if val.is_empty() {
×
1027
                ToolDirective::EnableAll
×
1028
            } else {
1029
                ToolDirective::Enable(val)
×
1030
            };
1031
            indexed.push((idx, directive));
×
1032
        }
1033

1034
        for (val, idx) in no_tool_values.into_iter().zip(no_tool_indices) {
2✔
1035
            let directive = if val.is_empty() {
×
1036
                ToolDirective::DisableAll
×
1037
            } else {
1038
                ToolDirective::Disable(val)
×
1039
            };
1040
            indexed.push((idx, directive));
×
1041
        }
1042

1043
        indexed.sort_by_key(|(idx, _)| *idx);
2✔
1044
        Ok(Self(indexed.into_iter().map(|(_, d)| d).collect()))
2✔
1045
    }
2✔
1046

1047
    fn update_from_arg_matches(
×
1048
        &mut self,
×
1049
        matches: &clap::ArgMatches,
×
1050
    ) -> std::result::Result<(), clap::Error> {
×
1051
        *self = Self::from_arg_matches(matches)?;
×
1052
        Ok(())
×
1053
    }
×
1054
}
1055

1056
impl clap::Args for ToolDirectives {
1057
    fn augment_args(cmd: clap::Command) -> clap::Command {
4✔
1058
        cmd.arg(
4✔
1059
            clap::Arg::new("tools")
4✔
1060
                .short('t')
4✔
1061
                .long("tool")
4✔
1062
                .alias("tools")
4✔
1063
                .help("The tool(s) to enable")
4✔
1064
                .long_help(
4✔
1065
                    "The tool(s) to enable.\n\nIf an existing tool is configured with a matching \
1066
                     name, it will be enabled for the duration of the query.\n\nIf no arguments \
1067
                     are provided, all configured tools will be enabled.\n\nYou can provide this \
1068
                     flag multiple times to enable multiple tools. Flags are evaluated \
1069
                     left-to-right, so `--no-tools --tool=write` first disables everything, then \
1070
                     re-enables only 'write'.",
1071
                )
1072
                .action(ArgAction::Append)
4✔
1073
                .num_args(0..=1)
4✔
1074
                .default_missing_value(""),
4✔
1075
        )
1076
        .arg(
4✔
1077
            clap::Arg::new("no_tools")
4✔
1078
                .short('T')
4✔
1079
                .long("no-tool")
4✔
1080
                .alias("no-tools")
4✔
1081
                .help("Disable tool(s)")
4✔
1082
                .long_help(
4✔
1083
                    "Disable tool(s).\n\nIf provided without a value, all enabled tools will be \
1084
                     disabled, otherwise pass the argument multiple times to disable one or more \
1085
                     tools.\n\nFlags are evaluated left-to-right together with `--tool`.",
1086
                )
1087
                .action(ArgAction::Append)
4✔
1088
                .num_args(0..=1)
4✔
1089
                .default_missing_value(""),
4✔
1090
        )
1091
    }
4✔
1092

1093
    fn augment_args_for_update(cmd: clap::Command) -> clap::Command {
×
1094
        Self::augment_args(cmd)
×
1095
    }
×
1096
}
1097

1098
/// Fork a conversation and return the new conversation's lock.
1099
fn fork_conversation(
×
1100
    ctx: &mut Ctx,
×
1101
    source: &ConversationHandle,
×
1102
    fork_turns: Option<usize>,
×
1103
) -> Result<ConversationLock> {
×
1104
    fork::fork_conversation(ctx, source, |events| {
×
1105
        if let Some(n) = fork_turns {
×
1106
            events.retain_last_turns(n);
×
1107
        }
×
1108
    })
×
1109
}
×
1110

1111
/// How a new conversation's title is set from its first prompt, before the turn
1112
/// runs.
1113
#[derive(Debug, PartialEq)]
1114
enum NewTitle {
1115
    /// Use this text, taken verbatim from a leading markdown heading.
1116
    FromHeading(String),
1117

1118
    /// Generate a title in the background via the LLM.
1119
    Generate,
1120

1121
    /// Leave the title unset.
1122
    Skip,
1123
}
1124

1125
/// Decide how to title a new conversation from its first prompt `content`.
1126
///
1127
/// A leading markdown heading wins when `from_heading` is enabled; otherwise
1128
/// background generation is chosen when `generate_auto` is enabled.
1129
/// The two flags are independent: disabling generation does not disable
1130
/// heading-derived titles.
1131
fn resolve_new_title(from_heading: bool, generate_auto: bool, content: &str) -> NewTitle {
6✔
1132
    if from_heading && let Some(title) = jp_md::heading::leading_heading(content) {
6✔
1133
        return NewTitle::FromHeading(title);
2✔
1134
    }
4✔
1135

1136
    if generate_auto {
4✔
1137
        return NewTitle::Generate;
2✔
1138
    }
2✔
1139

1140
    NewTitle::Skip
2✔
1141
}
6✔
1142

1143
/// Apply `--title` / `--no-title` to the resolved conversation.
1144
///
1145
/// Both flags act on `metadata.title` directly so the run ends with the title
1146
/// the user asked for, regardless of whether the conversation is new, freshly
1147
/// forked (which inherits the source's title), or resumed:
1148
///
1149
/// - `--title T` sets the title to `Some(T)`.
1150
/// - `--no-title` clears any existing title.
1151
/// - Neither flag is a no-op.
1152
fn apply_title_override(lock: &ConversationLock, title: Option<&str>, no_title: bool) {
6✔
1153
    if let Some(title) = title {
6✔
1154
        lock.as_mut().update_metadata(|m| {
1✔
1155
            m.title = Some(title.to_owned());
1✔
1156
        });
1✔
1157
    } else if no_title {
5✔
1158
        lock.as_mut().update_metadata(|m| {
2✔
1159
            m.title = None;
2✔
1160
        });
2✔
1161
    }
3✔
1162
}
6✔
1163

1164
fn get_config_delta_from_cli(
5✔
1165
    cfg: &AppConfig,
5✔
1166
    lock: &ConversationLock,
5✔
1167
) -> Result<Option<PartialAppConfig>> {
5✔
1168
    let partial = lock
5✔
1169
        .events()
5✔
1170
        .config()
5✔
1171
        .map(|c| c.to_partial())
5✔
1172
        .map_err(jp_conversation::Error::from)?;
5✔
1173

1174
    let partial = partial.delta(cfg.to_partial());
5✔
1175

1176
    if partial.is_empty() {
5✔
1177
        return Ok(None);
×
1178
    }
5✔
1179

1180
    Ok(Some(partial))
5✔
1181
}
5✔
1182

1183
impl IntoPartialAppConfig for Query {
1184
    fn apply_cli_config(
22✔
1185
        &self,
22✔
1186
        workspace: Option<&Workspace>,
22✔
1187
        mut partial: PartialAppConfig,
22✔
1188
        merged_config: Option<&PartialAppConfig>,
22✔
1189
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
22✔
1190
        let Self {
1191
            model,
22✔
1192
            template: _,
1193
            schema: _,
1194
            replay: _,
1195
            new_conversation: _,
1196
            local: _,
1197
            no_local: _,
1198
            attachments,
22✔
1199
            edit,
22✔
1200
            no_edit,
22✔
1201
            quote: _,
1202
            tool_use,
22✔
1203
            no_tool_use,
22✔
1204
            query: _,
1205
            parameters,
22✔
1206
            hide_reasoning,
22✔
1207
            hide_tool_calls,
22✔
1208
            tool_directives,
22✔
1209
            reasoning,
22✔
1210
            no_reasoning,
22✔
1211
            expires_in: _,
1212
            target: _,
1213
            fork: _,
1214
            compact: _,
1215
            title: _,
1216
            no_title: _,
1217
            mount,
22✔
1218
        } = &self;
22✔
1219

1220
        apply_model(&mut partial, model.as_deref(), merged_config);
22✔
1221
        apply_editor(&mut partial, edit.as_ref().map(|v| v.as_ref()), *no_edit);
22✔
1222

1223
        // Inject builtin tool configs before tool-enable processing.
1224
        for (name, config) in tool::builtins::all() {
22✔
1225
            partial
22✔
1226
                .conversation
22✔
1227
                .tools
22✔
1228
                .tools
22✔
1229
                .entry(name)
22✔
1230
                .or_insert(config);
22✔
1231
        }
22✔
1232

1233
        apply_enable_tools(&mut partial, tool_directives, merged_config)?;
22✔
1234
        apply_tool_use(
22✔
1235
            &mut partial,
22✔
1236
            tool_use.as_ref().map(|v| v.as_deref()),
22✔
1237
            *no_tool_use,
22✔
1238
        )?;
×
1239
        apply_attachments(&mut partial, attachments, workspace)?;
22✔
1240
        apply_mounts(&mut partial, mount, workspace, merged_config)?;
22✔
1241
        apply_reasoning(&mut partial, reasoning.as_ref(), *no_reasoning);
22✔
1242

1243
        for kv in parameters.clone() {
22✔
1244
            partial.assistant.model.parameters.assign(kv)?;
×
1245
        }
1246

1247
        if *hide_reasoning {
22✔
1248
            partial.style.reasoning.display = Some(ReasoningDisplayConfig::Hidden);
×
1249
        }
22✔
1250

1251
        if *hide_tool_calls {
22✔
1252
            partial.style.tool_call.show = Some(false);
×
1253
        }
22✔
1254

1255
        Ok(partial)
22✔
1256
    }
22✔
1257

1258
    fn apply_conversation_config(
4✔
1259
        &self,
4✔
1260
        workspace: &Workspace,
4✔
1261
        partial: PartialAppConfig,
4✔
1262
        _: Option<&PartialAppConfig>,
4✔
1263
        handle: &ConversationHandle,
4✔
1264
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
4✔
1265
        let config = workspace.events(handle)?.config().map(|c| c.to_partial())?;
4✔
1266

1267
        load_partial(partial, config).map_err(Into::into)
4✔
1268
    }
4✔
1269
}
1270

1271
/// Build the sorted list of system prompt sections from assistant config.
1272
///
1273
/// Used by both [`build_thread`] and [`LlmInquiryBackend`] construction to
1274
/// ensure the inquiry backend sees the same sections as the main thread.
1275
///
1276
/// [`LlmInquiryBackend`]: crate::cmd::query::tool::inquiry::LlmInquiryBackend
1277
pub(super) fn build_sections(assistant: &AssistantConfig, has_tools: bool) -> Vec<SectionConfig> {
100✔
1278
    let mut sections: Vec<_> = assistant.system_prompt_sections.clone();
100✔
1279
    sections.extend(
100✔
1280
        assistant
100✔
1281
            .instructions
100✔
1282
            .iter()
100✔
1283
            .map(InstructionsConfig::to_section),
100✔
1284
    );
1285

1286
    if has_tools {
100✔
1287
        let tool_section = InstructionsConfig::default()
42✔
1288
            .with_title("Tool Usage")
42✔
1289
            .with_description("How to leverage the tools available to you.".to_string())
42✔
1290
            .with_item("Use all the tools available to you to give the best possible answer.")
42✔
1291
            .with_item("Verify the tool name, description and parameters are correct.")
42✔
1292
            .with_item(
42✔
1293
                "Even if you've reasoned yourself towards a solution, use any available tool to \
42✔
1294
                 verify your answer.",
42✔
1295
            )
42✔
1296
            .to_section();
42✔
1297

42✔
1298
        sections.push(tool_section);
42✔
1299
    }
58✔
1300

1301
    sections.sort_by_key(|s| s.position);
100✔
1302
    sections
100✔
1303
}
100✔
1304

1305
fn build_thread(
63✔
1306
    events: ConversationStream,
63✔
1307
    attachments: Vec<Attachment>,
63✔
1308
    assistant: &AssistantConfig,
63✔
1309
    has_tools: bool,
63✔
1310
) -> Result<Thread> {
63✔
1311
    let sections = build_sections(assistant, has_tools);
63✔
1312

1313
    let mut thread_builder = ThreadBuilder::default()
63✔
1314
        .with_sections(sections)
63✔
1315
        .with_attachments(attachments)
63✔
1316
        .with_events(events);
63✔
1317

1318
    if let Some(system_prompt) = assistant.system_prompt.clone() {
63✔
1319
        thread_builder = thread_builder.with_system_prompt(system_prompt);
63✔
1320
    }
63✔
1321

1322
    Ok(thread_builder.build()?)
63✔
1323
}
63✔
1324

1325
/// Apply the CLI model configuration to the partial configuration.
1326
fn apply_model(partial: &mut PartialAppConfig, model: Option<&str>, _: Option<&PartialAppConfig>) {
22✔
1327
    let Some(id) = model else { return };
22✔
1328

1329
    partial.assistant.model.id = id.into();
4✔
1330
}
22✔
1331

1332
/// Apply the CLI editor configuration to the partial configuration.
1333
fn apply_editor(partial: &mut PartialAppConfig, editor: Option<Option<&Editor>>, no_edit: bool) {
22✔
1334
    let Some(Some(editor)) = editor else {
×
1335
        return;
22✔
1336
    };
1337

1338
    match (no_edit, editor) {
×
1339
        (true, _) | (_, Editor::Disabled) => {
×
1340
            partial.editor.cmd = None;
×
1341
            partial.editor.envs = None;
×
1342
        }
×
1343
        (_, Editor::Default) => {}
×
1344
        (_, Editor::Command(cmd)) => partial.editor.cmd = Some(cmd.clone()),
×
1345
    }
1346
}
22✔
1347

1348
fn apply_enable_tools(
22✔
1349
    partial: &mut PartialAppConfig,
22✔
1350
    directives: &ToolDirectives,
22✔
1351
    merged_config: Option<&PartialAppConfig>,
22✔
1352
) -> BoxedResult<()> {
22✔
1353
    if directives.is_empty() {
22✔
1354
        return Ok(());
10✔
1355
    }
12✔
1356

1357
    let existing_tools = merged_config.map_or(&partial.conversation.tools.tools, |v| {
12✔
1358
        &v.conversation.tools.tools
×
1359
    });
×
1360

1361
    // Validate all named tools exist.
1362
    let missing: HashSet<_> = directives
12✔
1363
        .iter()
12✔
1364
        .filter_map(ToolDirective::as_single)
12✔
1365
        .filter(|name| !existing_tools.contains_key(*name))
12✔
1366
        .collect();
12✔
1367

1368
    if missing.len() == 1 {
12✔
1369
        return Err(ToolError::NotFound {
×
1370
            name: missing.iter().next().unwrap().to_string(),
×
1371
        }
×
1372
        .into());
×
1373
    } else if !missing.is_empty() {
12✔
1374
        return Err(ToolError::NotFoundN {
×
1375
            names: missing.into_iter().map(ToString::to_string).collect(),
×
1376
        }
×
1377
        .into());
×
1378
    }
12✔
1379

1380
    // Validate that core tools are not disabled by name.
1381
    for d in directives.iter() {
19✔
1382
        if let ToolDirective::Disable(name) = d
19✔
1383
            && let Some(tool) = partial.conversation.tools.tools.get(name.as_str())
3✔
1384
            && tool.enable.is_some_and(Enable::is_always)
3✔
1385
        {
1386
            return Err(format!("Tool '{name}' is a system tool and cannot be disabled").into());
×
1387
        }
19✔
1388
    }
1389

1390
    // Apply directives left-to-right.
1391
    for d in directives.iter() {
19✔
1392
        match d {
19✔
1393
            ToolDirective::EnableAll => {
1394
                partial
5✔
1395
                    .conversation
5✔
1396
                    .tools
5✔
1397
                    .tools
5✔
1398
                    .iter_mut()
5✔
1399
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_explicit))
25✔
1400
                    .for_each(|(_, v)| v.enable = Some(Enable::On));
21✔
1401
            }
1402
            ToolDirective::DisableAll => {
1403
                partial
4✔
1404
                    .conversation
4✔
1405
                    .tools
4✔
1406
                    .tools
4✔
1407
                    .iter_mut()
4✔
1408
                    .filter(|(_, v)| !v.enable.is_some_and(Enable::is_always))
20✔
1409
                    .for_each(|(_, v)| v.enable = Some(Enable::Off));
17✔
1410
            }
1411
            ToolDirective::Enable(name) => {
7✔
1412
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
7✔
1413
                    tool.enable = Some(Enable::On);
7✔
1414
                }
7✔
1415
            }
1416
            ToolDirective::Disable(name) => {
3✔
1417
                if let Some(tool) = partial.conversation.tools.tools.get_mut(name.as_str()) {
3✔
1418
                    tool.enable = Some(Enable::Off);
3✔
1419
                }
3✔
1420
            }
1421
        }
1422
    }
1423

1424
    Ok(())
12✔
1425
}
22✔
1426

1427
/// Apply the CLI tool use configuration to the partial configuration.
1428
///
1429
/// NOTE: This has to run *after* `apply_enable_tools` because it will return an
1430
/// error if the tool of choice is not enabled.
1431
fn apply_tool_use(
22✔
1432
    partial: &mut PartialAppConfig,
22✔
1433
    tool_choice: Option<Option<&str>>,
22✔
1434
    no_tool_choice: bool,
22✔
1435
) -> BoxedResult<()> {
22✔
1436
    if no_tool_choice || matches!(tool_choice, Some(Some("false"))) {
22✔
1437
        partial.assistant.tool_choice = Some(ToolChoice::None);
×
1438
        return Ok(());
×
1439
    }
22✔
1440

1441
    let Some(tool) = tool_choice else {
22✔
1442
        return Ok(());
22✔
1443
    };
1444

1445
    partial.assistant.tool_choice = match tool {
×
1446
        None | Some("true") => Some(ToolChoice::Required),
×
1447
        Some(v) => {
×
1448
            if !partial
×
1449
                .conversation
×
1450
                .tools
×
1451
                .tools
×
1452
                .iter()
×
1453
                .filter(|(_, cfg)| cfg.enable.is_some_and(Enable::is_on))
×
1454
                .any(|(name, _)| name == v)
×
1455
            {
1456
                return Err(format!("tool choice '{v}' does not match any enabled tools").into());
×
1457
            }
×
1458

1459
            Some(ToolChoice::Function(v.to_owned()))
×
1460
        }
1461
    };
1462

1463
    Ok(())
×
1464
}
22✔
1465

1466
/// Apply the CLI attachments to the partial configuration.
1467
fn apply_attachments(
22✔
1468
    partial: &mut PartialAppConfig,
22✔
1469
    attachments: &[AttachmentUrlOrPath],
22✔
1470
    workspace: Option<&Workspace>,
22✔
1471
) -> Result<()> {
22✔
1472
    let root = workspace.map(Workspace::root);
22✔
1473
    let attachments = attachments
22✔
1474
        .iter()
22✔
1475
        .map(|v| v.parse(root))
22✔
1476
        .collect::<Result<Vec<_>>>()?;
22✔
1477

1478
    partial
22✔
1479
        .conversation
22✔
1480
        .attachments
22✔
1481
        .extend(attachments.into_iter().map(Into::into));
22✔
1482

1483
    Ok(())
22✔
1484
}
22✔
1485

1486
/// Apply the CLI reasoning configuration to the partial configuration.
1487
fn apply_reasoning(
22✔
1488
    partial: &mut PartialAppConfig,
22✔
1489
    reasoning: Option<&ReasoningConfig>,
22✔
1490
    no_reasoning: bool,
22✔
1491
) {
22✔
1492
    if no_reasoning {
22✔
1493
        partial.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Off);
×
1494
        return;
×
1495
    }
22✔
1496

1497
    let Some(reasoning) = reasoning else {
22✔
1498
        return;
22✔
1499
    };
1500

1501
    partial.assistant.model.parameters.reasoning = Some(match reasoning {
×
1502
        ReasoningConfig::Off => PartialReasoningConfig::Off,
×
1503
        ReasoningConfig::Auto => PartialReasoningConfig::Auto,
×
1504
        ReasoningConfig::Custom(custom) => PartialCustomReasoningConfig {
×
1505
            effort: Some(custom.effort),
×
1506
            exclude: Some(custom.exclude),
×
1507
        }
×
1508
        .into(),
×
1509
    });
1510
}
22✔
1511

1512
/// A resolved mount and the tools it grants access to (stage 1 planning).
1513
struct MountPlan {
1514
    rule_path: String,
1515
    write: bool,
1516
    /// (tool name, whether its `access.fs` is empty across all layers)
1517
    targets: Vec<(String, bool)>,
1518
}
1519

1520
/// Inject `--mount` access grants into the partial config (stage 1).
1521
///
1522
/// Pure config mutation: one `access.fs` rule per in-scope tool.
1523
/// The symlink is not required to exist yet; it is created later in
1524
/// [`Query::run`].
1525
/// When a tool had no filesystem rules from any layer, a workspace-default rule
1526
/// is also injected so the mount doesn't silently switch the tool to deny-all.
1527
fn apply_mounts(
22✔
1528
    partial: &mut PartialAppConfig,
22✔
1529
    mounts: &[String],
22✔
1530
    workspace: Option<&Workspace>,
22✔
1531
    merged_config: Option<&PartialAppConfig>,
22✔
1532
) -> BoxedResult<()> {
22✔
1533
    if mounts.is_empty() {
22✔
1534
        return Ok(());
22✔
1535
    }
×
1536

1537
    let workspace = workspace.ok_or("`--mount` requires a workspace")?;
×
1538
    let root = workspace.root().to_owned();
×
1539
    let cwd = current_dir_utf8()?;
×
1540

1541
    // Resolve the tool set and the global enable default from the merged
1542
    // config (the fully-layered view) so a bare mount expands over the tools
1543
    // actually enabled in the resolved config, honoring `*` defaults.
1544
    let tools_config = merged_config.map_or(&partial.conversation.tools, |v| &v.conversation.tools);
×
1545
    let default_enable = tools_config.defaults.enable;
×
1546
    let existing = &tools_config.tools;
×
1547

1548
    let mut plans = Vec::new();
×
1549
    for spec in mounts {
×
1550
        let spec = MountSpec::parse(spec)?;
×
1551
        let rule_path = spec.resolve_name(&cwd, &root)?.as_str().to_owned();
×
1552

1553
        let targets = match &spec.tool {
×
1554
            Some(tool) => vec![(tool.clone(), tool_access_empty(existing, tool))],
×
1555
            None => existing
×
1556
                .iter()
×
1557
                .filter(|(_, cfg)| is_enabled_local(cfg, default_enable))
×
1558
                .map(|(name, _)| (name.clone(), tool_access_empty(existing, name)))
×
1559
                .collect(),
×
1560
        };
1561

1562
        plans.push(MountPlan {
×
1563
            rule_path,
×
1564
            write: spec.mode == MountMode::Rw,
×
1565
            targets,
×
1566
        });
×
1567
    }
1568

1569
    for plan in plans {
×
1570
        for (tool, access_empty) in plan.targets {
×
1571
            let cfg = partial.conversation.tools.tools.entry(tool).or_default();
×
1572
            let access = cfg.access.get_or_insert_with(PartialAccessConfig::default);
×
1573

1574
            let already_present = access
×
1575
                .fs
×
1576
                .iter()
×
1577
                .any(|rule| rule.path.as_deref() == Some(plan.rule_path.as_str()));
×
1578
            if already_present {
×
1579
                continue;
×
1580
            }
×
1581

1582
            if access_empty && access.fs.is_empty() {
×
1583
                access.fs.push(workspace_default_partial_rule());
×
1584
            }
×
1585

1586
            access
×
1587
                .fs
×
1588
                .push(mount_partial_rule(&plan.rule_path, plan.write));
×
1589
        }
1590
    }
1591

1592
    Ok(())
×
1593
}
22✔
1594

1595
/// Create the symlinks and seed the approval store for `--mount` flags (stage
1596
/// 2).
1597
fn create_mount_effects(
2✔
1598
    mounts: &[String],
2✔
1599
    workspace: &Workspace,
2✔
1600
    fs_backend: Option<&jp_storage::backend::FsStorageBackend>,
2✔
1601
    now: chrono::DateTime<chrono::Utc>,
2✔
1602
) -> Result<()> {
2✔
1603
    if mounts.is_empty() {
2✔
1604
        return Ok(());
2✔
1605
    }
×
1606

1607
    let root = workspace.root().to_owned();
×
1608
    let root_canonical = root.canonicalize_utf8().unwrap_or_else(|_| root.clone());
×
1609
    let cwd = current_dir_utf8().map_err(|e| Error::CliConfig(e.to_string()))?;
×
1610

1611
    let approvals_path = approval_store_path(fs_backend);
×
1612
    let mut store = approvals_path
×
1613
        .as_deref()
×
1614
        .map(ApprovalStore::load)
×
1615
        .unwrap_or_default();
×
1616

1617
    let mut rules = Vec::new();
×
1618
    for spec in mounts {
×
1619
        let spec = MountSpec::parse(spec).map_err(|e| Error::CliConfig(e.to_string()))?;
×
1620
        let rule_path = spec
×
1621
            .resolve_name(&cwd, &root)
×
1622
            .map_err(|e| Error::CliConfig(e.to_string()))?;
×
1623
        let link = root.join(&rule_path);
×
1624

1625
        let target = expand_tilde(&spec.path, env::var("HOME").ok())
×
1626
            .unwrap_or_else(|| Utf8PathBuf::from(&spec.path));
×
1627

1628
        // Resolve the target before creating the link so a missing target
1629
        // fails cleanly instead of leaving a broken symlink behind.
1630
        let canonical = target.canonicalize_utf8().map_err(|e| {
×
1631
            Error::CliConfig(format!("mount target '{target}' cannot be resolved: {e}"))
×
1632
        })?;
×
1633

1634
        // An external mount must point outside the workspace. Reject an
1635
        // in-workspace target before any side effect, so a rejected mount
1636
        // leaves no symlink or approval entry behind.
1637
        if canonical.starts_with(&root_canonical) {
×
1638
            return Err(Error::CliConfig(format!(
×
1639
                "mount target '{target}' is inside the workspace; mounts are for external paths"
×
1640
            )));
×
1641
        }
×
1642

1643
        // Link to the canonical absolute target so the symlink resolves the
1644
        // same regardless of where it sits and matches the recorded approval
1645
        // (a relative target would resolve against the link's parent instead).
1646
        create_workspace_symlink(&link, &canonical)?;
×
1647
        store.record(rule_path.as_str(), canonical, now);
×
1648
        rules.push(spec.rule(rule_path.as_str()));
×
1649
    }
1650

1651
    if let Some(path) = approvals_path {
×
1652
        store.save(&path)?;
×
1653
    }
×
1654

1655
    // Compile the just-created mounts against the seeded approvals to confirm
1656
    // they resolve to a usable policy, surfacing broken or unapproved targets.
1657
    let access = AccessConfig { fs: rules };
×
1658
    let (_, warnings) = compile_policy(&access, &root, |rule_path, candidate| {
×
1659
        match store.lookup(rule_path, candidate) {
×
1660
            ApprovalLookup::Approved => ApprovalDecision::Approved,
×
1661
            ApprovalLookup::Retargeted { .. } | ApprovalLookup::Unknown => {
1662
                ApprovalDecision::Rejected
×
1663
            }
1664
        }
1665
    })
×
1666
    .map_err(|e| Error::CliConfig(e.to_string()))?;
×
1667

1668
    for warning in warnings {
×
1669
        warn!("{warning}");
×
1670
    }
1671

1672
    Ok(())
×
1673
}
2✔
1674

1675
/// Create a workspace symlink at `link` pointing to `target`.
1676
///
1677
/// A symlink that already resolves to the same target is a no-op; one resolving
1678
/// elsewhere, or a non-symlink at `link`, is an error.
1679
fn create_workspace_symlink(link: &Utf8Path, target: &Utf8Path) -> Result<()> {
×
1680
    if link.is_symlink() {
×
1681
        // Compare resolved targets rather than the raw link text, so a relative
1682
        // and an absolute link to the same place are treated as identical.
1683
        let same = link
×
1684
            .canonicalize_utf8()
×
1685
            .ok()
×
1686
            .zip(target.canonicalize_utf8().ok())
×
1687
            .is_some_and(|(existing, wanted)| existing == wanted);
×
1688
        if same {
×
1689
            return Ok(());
×
1690
        }
×
1691
        return Err(Error::CliConfig(format!(
×
1692
            "mount '{link}' already exists as a symlink pointing elsewhere"
×
1693
        )));
×
1694
    }
×
1695

1696
    if link.exists() {
×
1697
        return Err(Error::CliConfig(format!(
×
1698
            "cannot create mount: '{link}' already exists and is not a symlink"
×
1699
        )));
×
1700
    }
×
1701

1702
    if let Some(parent) = link.parent() {
×
1703
        fs::create_dir_all(parent)?;
×
1704
    }
×
1705

1706
    #[cfg(unix)]
1707
    std::os::unix::fs::symlink(target.as_std_path(), link.as_std_path())?;
×
1708

1709
    #[cfg(windows)]
1710
    if target.is_dir() {
1711
        std::os::windows::fs::symlink_dir(target.as_std_path(), link.as_std_path())?;
1712
    } else {
1713
        std::os::windows::fs::symlink_file(target.as_std_path(), link.as_std_path())?;
1714
    }
1715

1716
    Ok(())
×
1717
}
×
1718

1719
/// Resolve the path to the user-local approval store, if user storage exists.
1720
fn approval_store_path(
×
1721
    fs_backend: Option<&jp_storage::backend::FsStorageBackend>,
×
1722
) -> Option<Utf8PathBuf> {
×
1723
    fs_backend
×
1724
        .and_then(|fs| fs.user_storage_with_path(relative_path::RelativePath::new(APPROVALS_FILE)))
×
1725
}
×
1726

1727
/// Load the approval store, treating missing/in-memory storage as empty.
1728
fn load_approval_store(
×
1729
    fs_backend: Option<&jp_storage::backend::FsStorageBackend>,
×
1730
) -> ApprovalStore {
×
1731
    approval_store_path(fs_backend)
×
1732
        .as_deref()
×
1733
        .map(ApprovalStore::load)
×
1734
        .unwrap_or_default()
×
1735
}
×
1736

1737
fn current_dir_utf8() -> BoxedResult<Utf8PathBuf> {
×
1738
    let cwd = env::current_dir()?;
×
1739
    Utf8PathBuf::from_path_buf(cwd)
×
1740
        .map_err(|path| format!("current directory is not valid UTF-8: {}", path.display()).into())
×
1741
}
×
1742

1743
/// Whether a tool's `access.fs` is empty across all merged layers.
1744
fn tool_access_empty(
×
1745
    tools: &IndexMap<String, jp_config::conversation::tool::PartialToolConfig>,
×
1746
    name: &str,
×
1747
) -> bool {
×
1748
    tools
×
1749
        .get(name)
×
1750
        .and_then(|cfg| cfg.access.as_ref())
×
1751
        .is_none_or(|access| access.fs.is_empty())
×
1752
}
×
1753

1754
/// Whether a partial tool config is an enabled local tool.
1755
///
1756
/// The tool's own `enable` takes precedence over the global `*` default; a tool
1757
/// that is `off` or `explicit` after that resolution is not part of a bare
1758
/// mount's scope.
1759
fn is_enabled_local(
×
1760
    cfg: &jp_config::conversation::tool::PartialToolConfig,
×
1761
    default_enable: Option<Enable>,
×
1762
) -> bool {
×
1763
    matches!(cfg.source, Some(ToolSource::Local { .. }))
×
1764
        && !matches!(
×
1765
            cfg.enable.or(default_enable),
×
1766
            Some(Enable::Off | Enable::Explicit)
1767
        )
1768
}
×
1769

1770
/// The workspace-default rule injected to preserve a tool's prior implicit
1771
/// workspace access.
1772
fn workspace_default_partial_rule() -> PartialFsRuleConfig {
×
1773
    PartialFsRuleConfig {
×
1774
        path: Some(".".to_owned()),
×
1775
        read: Some(true),
×
1776
        write: Some(true),
×
1777
        ..PartialFsRuleConfig::default()
×
1778
    }
×
1779
}
×
1780

1781
/// The `access.fs` rule a mount injects.
1782
fn mount_partial_rule(rule_path: &str, write: bool) -> PartialFsRuleConfig {
×
1783
    PartialFsRuleConfig {
×
1784
        path: Some(rule_path.to_owned()),
×
1785
        external: Some(true),
×
1786
        read: Some(true),
×
1787
        write: Some(write),
×
1788
        ..PartialFsRuleConfig::default()
×
1789
    }
×
1790
}
×
1791

1792
/// Set the terminal title to show the active conversation.
1793
fn set_terminal_title(id: ConversationId, title: Option<&str>) {
×
1794
    let display = match title {
×
1795
        Some(t) => format!("{id}: {t}"),
×
1796
        None => id.to_string(),
×
1797
    };
1798
    jp_term::osc::set_title(display);
×
1799
}
×
1800

1801
/// Parse a schema string as either a concise DSL or raw JSON Schema.
1802
#[expect(clippy::needless_pass_by_value)]
1803
fn parse_schema(s: String) -> Result<schemars::Schema> {
×
1804
    crate::schema::parse_schema_dsl(&s)
×
1805
        .map_err(|e| Error::Schema(e.to_string()))?
×
1806
        .try_into()
×
1807
        .map_err(Into::into)
×
1808
}
×
1809

1810
/// Parse the `--fork` value.
1811
/// Empty string means "all turns", a number means "keep last N turns".
1812
fn parse_fork_turns(s: &str) -> std::result::Result<Option<usize>, String> {
×
1813
    if s.is_empty() {
×
1814
        return Ok(None);
×
1815
    }
×
1816
    s.parse::<usize>()
×
1817
        .map(Some)
×
1818
        .map_err(|_| format!("expected a positive integer, got '{s}'"))
×
1819
}
×
1820

1821
fn string_or_path(s: &str) -> Result<String> {
×
1822
    if let Some(s) = s
×
1823
        .strip_prefix(PATH_STRING_PREFIX)
×
1824
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
1825
    {
1826
        return fs::read_to_string(s).map_err(Into::into);
×
1827
    }
×
1828

1829
    Ok(s.to_owned())
×
1830
}
×
1831

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

© 2026 Coveralls, Inc