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

dcdpr / jp / 20372941205

19 Dec 2025 02:26PM UTC coverage: 52.025% (+4.3%) from 47.733%
20372941205

push

github

web-flow
test(llm, conversation): Improve test determinism and infrastructure (#322)

This commit enhances test reliability and reproducibility by introducing
fixed timestamps and consistent ordering throughout the test suite.
Tests now use deterministic timestamps (`2020-01-01 00:00:00.0`) instead
of dynamic ones, ensuring snapshot tests remain stable across runs.

The `ConversationStream` now tracks its creation timestamp explicitly,
which is used consistently in serialization and test fixtures. This
change required updating the `add_config_delta()` method to accept a
`ConfigDelta` with an explicit timestamp rather than inferring it at
call time.

JSON serialization now preserves key ordering across all crates by
enabling the `preserve_order` feature on `serde_json`. This ensures
deterministic output in fixtures and snapshots, particularly important
for provider responses and configuration serialization.

Model-related types (`ModelIdConfig`, `ProviderId`, `Name`) now
implement ordering traits, enabling the openrouter provider to sort and
deduplicate model lists. This prevents duplicate entries in model
listings and provides consistent ordering for better test reliability.

Test fixture handling has been improved with better JSON body formatting
and more careful whitespace handling, particularly for server-sent
events that require specific newline patterns. The mock infrastructure
now better preserves the structure of HTTP responses in YAML fixtures.

Additional improvements include updating the `saphyr` dependency to fix
multi-newline ending issues, refining array type inference in OpenAI's
parameter sanitization, and removing large blocks of commented-out test
code that are no longer needed.

---------

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

103 of 134 new or added lines in 10 files covered. (76.87%)

1237 existing lines in 38 files now uncovered.

8774 of 16865 relevant lines covered (52.02%)

135.01 hits per line

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

26.99
/crates/jp_cli/src/cmd/query.rs
1
mod event;
2
mod response_handler;
3
mod turn;
4

5
use std::{
6
    collections::{BTreeMap, HashSet},
7
    env, fs,
8
    path::{Path, PathBuf},
9
    str::FromStr,
10
    sync::Arc,
11
    time::Duration,
12
};
13

14
use clap::{ArgAction, builder::TypedValueParser as _};
15
use event::StreamEventHandler;
16
use futures::StreamExt as _;
17
use itertools::Itertools as _;
18
use jp_attachment::Attachment;
19
use jp_config::{
20
    AppConfig, PartialAppConfig, PartialConfig as _,
21
    assignment::{AssignKeyValue as _, KvAssignment},
22
    assistant::{AssistantConfig, instructions::InstructionsConfig, tool_choice::ToolChoice},
23
    fs::{expand_tilde, load_partial},
24
    model::parameters::{PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig},
25
    style::reasoning::ReasoningDisplayConfig,
26
};
27
use jp_conversation::{
28
    Conversation, ConversationEvent, ConversationId, ConversationStream, EventKind,
29
    event::{ChatRequest, ChatResponse},
30
    thread::{Thread, ThreadBuilder},
31
};
32
use jp_llm::{
33
    ToolError,
34
    event::Event,
35
    provider,
36
    query::{ChatQuery, StructuredQuery},
37
    tool::{ToolDefinition, tool_definitions},
38
};
39
use jp_task::task::TitleGeneratorTask;
40
use jp_term::stdout;
41
use jp_workspace::Workspace;
42
use minijinja::{Environment, UndefinedBehavior};
43
use response_handler::ResponseHandler;
44
use serde_json::Value;
45
use tracing::{debug, error, info, trace, warn};
46
use url::Url;
47

48
use super::{Output, attachment::register_attachment};
49
use crate::{
50
    Ctx, PATH_STRING_PREFIX,
51
    cmd::{self, Success, query::turn::TurnState},
52
    ctx::IntoPartialAppConfig,
53
    editor::{self, Editor},
54
    error::{Error, Result},
55
    parser,
56
    signals::{SignalRx, SignalTo},
57
};
58

59
const EMPTY_RESPONSE_MESSAGE: &str = " -- The response appears to be empty. Please try again.";
60

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

63
#[derive(Debug, Default, clap::Args)]
64
pub(crate) struct Query {
65
    /// The query to send. If not provided, uses `$JP_EDITOR`, `$VISUAL` or
66
    /// `$EDITOR` to open edit the query in an editor.
67
    #[arg(value_parser = string_or_path)]
68
    query: Option<Vec<String>>,
69

70
    /// Use the query string as a Jinja2 template.
71
    ///
72
    /// You can provide values for template variables using the
73
    /// `template.values` config key.
74
    #[arg(long)]
75
    template: bool,
76

77
    #[arg(long, value_parser = string_or_path.try_map(json_schema))]
78
    schema: Option<schemars::Schema>,
79

80
    /// Replay the last message in the conversation.
81
    ///
82
    /// If a query is provided, it will be appended to the end of the previous
83
    /// message. If no query is provided, $EDITOR will open with the last
84
    /// message in the conversation.
85
    #[arg(long = "replay", conflicts_with = "new_conversation")]
86
    replay: bool,
87

88
    /// Start a new conversation without any message history.
89
    #[arg(short = 'n', long = "new")]
90
    new_conversation: bool,
91

92
    /// Store the conversation locally, outside of the workspace.
93
    #[arg(short = 'l', long = "local", requires = "new_conversation")]
94
    local: bool,
95

96
    /// Add attachment to the configuration.
97
    #[arg(short = 'a', long = "attachment", value_parser = |s: &str| parser::attachment_url(s))]
98
    attachments: Vec<Url>,
99

100
    /// Whether and how to edit the query.
101
    ///
102
    /// Setting this flag to `true`, omitting it, or using it as a boolean flag
103
    /// (e.g. `--edit`) will use the default editor configured elsewhere, or
104
    /// return an error if no editor is configured and one is required.
105
    ///
106
    /// If set to `false`, the editor will be disabled (similar to `--no-edit`),
107
    /// which might result in an error if the editor is required.
108
    ///
109
    /// If set to any other value, it will be used as the command to open the
110
    /// editor.
111
    #[arg(short = 'e', long = "edit", conflicts_with = "no_edit")]
112
    edit: Option<Option<Editor>>,
113

114
    /// Do not edit the query.
115
    ///
116
    /// See `--edit` for more details.
117
    #[arg(short = 'E', long = "no-edit", conflicts_with = "edit")]
118
    no_edit: bool,
119

120
    /// The model to use.
121
    #[arg(short = 'm', long = "model")]
122
    model: Option<String>,
123

124
    /// The model parameters to use.
125
    #[arg(short = 'p', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append, value_parser = KvAssignment::from_str)]
126
    parameters: Vec<KvAssignment>,
127

128
    /// Enable reasoning.
129
    #[arg(short = 'r', long = "reasoning")]
130
    reasoning: Option<ReasoningConfig>,
131

132
    /// Disable reasoning.
133
    #[arg(short = 'R', long = "no-reasoning")]
134
    no_reasoning: bool,
135

136
    /// Do not display the reasoning content.
137
    ///
138
    /// This does not stop the assistant from generating reasoning tokens to
139
    /// help with its accuracy, but it does not display them in the output.
140
    #[arg(long = "hide-reasoning")]
141
    hide_reasoning: bool,
142

143
    /// Do not display tool calls.
144
    ///
145
    /// This does not stop the assistant from running tool calls, but it does
146
    /// not display them in the output.
147
    #[arg(long = "hide-tool-calls")]
148
    hide_tool_calls: bool,
149

150
    /// Stream the assistant's response as it is generated.
151
    ///
152
    /// This is the default behaviour for TTY sessions, but can be forced for
153
    /// non-TTY sessions by setting this flag.
154
    #[arg(short = 's', long = "stream", conflicts_with = "no_stream")]
155
    stream: bool,
156

157
    /// Disable streaming the assistant's response.
158
    ///
159
    /// This is the default behaviour for non-TTY sessions, or for structured
160
    /// responses, but can be forced by setting this flag.
161
    #[arg(short = 'S', long = "no-stream", conflicts_with = "stream")]
162
    no_stream: bool,
163

164
    /// The tool(s) to enable.
165
    ///
166
    /// If an existing tool is configured with a matching name, it will be
167
    /// enabled for the duration of the query.
168
    ///
169
    /// If no arguments are provided, all configured tools will be enabled.
170
    ///
171
    /// You can provide this flag multiple times to enable multiple tools. It
172
    /// can be combined with `--no-tools` to disable all enabled tools before
173
    /// enabling a specific one.
174
    #[arg(
175
        short = 't',
176
        long = "tool",
177
        action = ArgAction::Append,
178
        num_args = 0..=1,
179
        value_parser = |s: &str| -> Result<Option<String>> {
180
            if s.is_empty() { Ok(None) } else { Ok(Some(s.to_string())) }
181
        },
182
        default_missing_value = "",
183
    )]
184
    tools: Vec<Option<String>>,
185

186
    /// Disable tools.
187
    ///
188
    /// If provided without a value, all enabled tools will be disabled,
189
    /// otherwise pass the argument multiple times to disable one or more tools.
190
    ///
191
    /// Any tools that were enabled before this flag is set will be disabled.
192
    #[arg(
193
        short = 'T',
194
        long = "no-tools",
195
        action = ArgAction::Append,
196
        num_args = 0..=1,
197
        value_parser = |s: &str| -> Result<Option<String>> {
198
            if s.is_empty() { Ok(None) } else { Ok(Some(s.to_string())) }
199
        },
200
        default_missing_value = "",
201
    )]
202
    no_tools: Vec<Option<String>>,
203

204
    /// The tool to use.
205
    ///
206
    /// If a value is provided, the tool matching the value will be used.
207
    ///
208
    /// Note that this setting is *not* persisted across queries. To persist
209
    /// tool choice behavior, set the `assistant.tool_choice` field in a
210
    /// configuration file.
211
    #[arg(short = 'u', long = "tool-use")]
212
    tool_use: Option<Option<String>>,
213

214
    /// Disable tool use by the assistant.
215
    #[arg(short = 'U', long = "no-tool-use")]
216
    no_tool_use: bool,
217
}
218

219
/// How to render the response to the user.
220
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
221
pub(crate) enum RenderMode {
222
    /// Use the default render mode, depending on whether the output is a TTY,
223
    /// and if a structured response is requested.
224
    #[default]
225
    Auto,
226

227
    /// Render the response as a stream of tokens.
228
    Streamed,
229

230
    /// Render the response as a buffered string.
231
    Buffered,
232
}
233

234
impl RenderMode {
235
    pub fn is_streamed(self) -> bool {
×
UNCOV
236
        matches!(self, Self::Streamed)
×
UNCOV
237
    }
×
238
}
239

240
impl Query {
241
    #[expect(clippy::too_many_lines)]
242
    pub(crate) async fn run(self, ctx: &mut Ctx) -> Output {
×
243
        debug!("Running `query` command.");
×
UNCOV
244
        trace!(args = ?self, "Received arguments.");
×
245
        let cfg = ctx.config();
×
246

NEW
247
        let previous_id = self.update_active_conversation(&mut ctx.workspace, cfg.clone())?;
×
248
        let conversation_id = ctx.workspace.active_conversation_id();
×
249
        if let Some(delta) = get_config_delta_from_cli(&cfg, &ctx.workspace, &conversation_id)? {
×
250
            ctx.workspace
×
251
                .get_events_mut(&conversation_id)
×
252
                .expect(
×
UNCOV
253
                    "TODO: add this invariant to the type system. FIXME: This can actually happen \
×
254
                     right now if the `events.json` file of the active conversation is corrupt, \
×
UNCOV
255
                     and thus not loaded into memory.",
×
256
                )
×
257
                .add_config_delta(delta);
×
258
        }
×
259

260
        ctx.configure_active_mcp_servers().await?;
×
261

262
        let root = ctx
×
263
            .workspace
×
264
            .storage_path()
×
265
            .unwrap_or(&ctx.workspace.root)
×
266
            .to_path_buf();
×
267

268
        let conversation = ctx.workspace.get_conversation(&conversation_id);
×
UNCOV
269
        let conversation_path = root.join(
×
270
            conversation_id.to_dirname(conversation.as_ref().and_then(|v| v.title.as_deref())),
×
271
        );
272

273
        let (query_file, editor_provided_config) = self.build_conversation(
×
UNCOV
274
            ctx.workspace
×
275
                .get_events_mut(&conversation_id)
×
276
                .expect("TODO: add this invariant to the type system"),
×
UNCOV
277
            &cfg,
×
278
            &conversation_path,
×
279
        )?;
×
280

281
        let has_request = ctx
×
282
            .workspace
×
283
            .get_events(&conversation_id)
×
UNCOV
284
            .and_then(ConversationStream::last)
×
285
            .and_then(|v| v.as_chat_request().map(|v| !v.is_empty()))
×
286
            .unwrap_or(false);
×
287

288
        if !has_request {
×
289
            return cleanup(ctx, previous_id, query_file.as_deref()).map_err(Into::into);
×
UNCOV
290
        }
×
291

292
        if !editor_provided_config.is_empty() {
×
293
            ctx.workspace
×
294
                .get_events_mut(&conversation_id)
×
UNCOV
295
                .expect("TODO: add this invariant to the type system")
×
296
                .add_config_delta(editor_provided_config);
×
297
        }
×
298

299
        let stream = ctx
×
300
            .workspace
×
301
            .get_events(&conversation_id)
×
302
            .cloned()
×
NEW
303
            .unwrap_or_else(|| ConversationStream::new(cfg.clone()));
×
304

305
        // Generate title for new or empty conversations.
306
        if (self.new_conversation || stream.is_empty())
×
307
            && ctx.term.args.persist
×
308
            && cfg.conversation.title.generate.auto
×
309
        {
UNCOV
310
            debug!("Generating title for new conversation");
×
UNCOV
311
            ctx.task_handler.spawn(TitleGeneratorTask::new(
×
UNCOV
312
                conversation_id,
×
313
                stream.clone(),
×
UNCOV
314
                &cfg,
×
315
            )?);
×
UNCOV
316
        }
×
317

318
        let tools = tool_definitions(cfg.conversation.tools.iter(), &ctx.mcp_client).await?;
×
319

320
        let mut attachments = vec![];
×
321
        for attachment in &cfg.conversation.attachments {
×
322
            register_attachment(ctx, &attachment.to_url()?, &mut attachments).await?;
×
323
        }
324

325
        // Keep track of the number of events in the stream, so that we can
326
        // later append new events to the end.
UNCOV
327
        let current_events = stream.len();
×
328

329
        let mut thread = build_thread(stream, attachments, &cfg.assistant, &tools)?;
×
330

331
        let mut result = Output::Ok(Success::Ok);
×
332
        if let Some(schema) = self.schema.clone() {
×
333
            result = handle_structured_output(
×
334
                &cfg,
×
335
                ctx.term.is_tty,
×
336
                &mut thread,
×
337
                schema,
×
338
                self.render_mode(),
×
339
            )
×
340
            .await;
×
341
        } else {
342
            let mut turn_state = TurnState::default();
×
343
            if let Err(error) = self
×
344
                .handle_stream(
×
345
                    &cfg,
×
UNCOV
346
                    &mut ctx.signals.receiver,
×
UNCOV
347
                    &ctx.mcp_client,
×
348
                    ctx.workspace.root.clone(),
×
349
                    ctx.term.is_tty,
×
350
                    &mut turn_state,
×
351
                    &mut thread,
×
UNCOV
352
                    cfg.assistant.tool_choice.clone(),
×
353
                    tools,
×
354
                    conversation_id,
×
355
                )
UNCOV
356
                .await
×
UNCOV
357
            {
×
358
                result = Output::Err(cmd::Error::from(error).with_persistence(true));
×
359
            }
×
360
        }
361

362
        let stream = ctx
×
363
            .workspace
×
UNCOV
364
            .get_events_mut(&conversation_id)
×
365
            .expect("TODO: add this invariant to the type system");
×
366

367
        for event in thread.events.into_iter().skip(current_events) {
×
368
            stream.push_with_config_delta(event);
×
369
        }
×
370

371
        // Clean up the query file, unless we got an error.
UNCOV
372
        if let Some(path) = query_file
×
UNCOV
373
            && result.is_ok()
×
374
        {
UNCOV
375
            fs::remove_file(path)?;
×
UNCOV
376
        }
×
377

378
        result
×
379
    }
×
380

381
    fn build_conversation(
×
UNCOV
382
        &self,
×
UNCOV
383
        stream: &mut ConversationStream,
×
UNCOV
384
        config: &AppConfig,
×
UNCOV
385
        root: &Path,
×
386
    ) -> Result<(Option<PathBuf>, PartialAppConfig)> {
×
387
        // If replaying, remove all events up-to-and-including the last
388
        // `ChatRequest` event, which we'll replay.
389
        //
390
        // If not replaying (or replaying but no chat request event exists), we
391
        // create a new `ChatRequest` event, to populate with either the
392
        // provided query, or the contents of the text editor.
UNCOV
393
        let mut request = self
×
394
            .replay
×
395
            .then(|| stream.trim_chat_request())
×
396
            .flatten()
×
397
            .unwrap_or_default();
×
398

399
        // If a query is provided, prepend it to the chat request. This is only
400
        // relevant for replays, otherwise the chat request is still empty, so
401
        // we replace it with the provided query.
402
        if let Some(text) = &self.query {
×
403
            let text = text.join(" ");
×
404
            let sep = if request.is_empty() { "" } else { "\n\n" };
×
UNCOV
405
            *request = format!("{text}{sep}{request}");
×
406
        }
×
407

UNCOV
408
        let editor_details = self.edit_message(&mut request, stream, config, root)?;
×
409

410
        if self.template {
×
UNCOV
411
            let mut env = Environment::empty();
×
412
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
UNCOV
413
            env.add_template("query", &request.content)?;
×
414

415
            let tmpl = env.get_template("query")?;
×
416
            // TODO: supported nested variables
417
            for var in tmpl.undeclared_variables(false) {
×
418
                if config.template.values.contains_key(&var) {
×
419
                    continue;
×
420
                }
×
421

UNCOV
422
                return Err(Error::TemplateUndefinedVariable(var));
×
423
            }
424

425
            *request = tmpl.render(&config.template.values)?;
×
UNCOV
426
        }
×
427

428
        stream.add_chat_request(request);
×
429

UNCOV
430
        Ok(editor_details)
×
431
    }
×
432

UNCOV
433
    fn update_active_conversation(
×
434
        &self,
×
UNCOV
435
        ws: &mut Workspace,
×
NEW
436
        cfg: Arc<AppConfig>,
×
437
    ) -> Result<ConversationId> {
×
438
        // Store the (old) active conversation ID, so that we can restore to it,
439
        // if the current conversation is aborted early (e.g. because of an
440
        // empty query or any other error).
441
        let last_active_conversation_id = ws.active_conversation_id();
×
442

443
        // Set new active conversation if requested.
444
        if self.new_conversation {
×
445
            let id = ws.create_conversation(Conversation::default().with_local(self.local), cfg);
×
446

447
            debug!(
×
448
                %id,
449
                local = %self.local,
450
                "Creating new active conversation due to --new flag."
×
451
            );
452

UNCOV
453
            ws.set_active_conversation_id(id)?;
×
UNCOV
454
        }
×
455

456
        Ok(last_active_conversation_id)
×
457
    }
×
458

459
    // Open the editor for the query, if requested.
UNCOV
460
    fn edit_message(
×
UNCOV
461
        &self,
×
462
        request: &mut ChatRequest,
×
463
        stream: &mut ConversationStream,
×
464
        config: &AppConfig,
×
UNCOV
465
        root: &Path,
×
466
    ) -> Result<(Option<PathBuf>, PartialAppConfig)> {
×
467
        // If there is no query provided, but the user explicitly requested not
468
        // to edit the query, we populate the query with a default message,
469
        // since most LLM providers do not support empty queries.
470
        //
471
        // See `force_no_edit` why this can be useful.
472
        if request.is_empty() && self.force_no_edit() {
×
473
            "<no additional context provided>".clone_into(request);
×
474
        }
×
475

476
        // If a query is provided, and editing is not explicitly requested, we
477
        // omit opening the editor.
UNCOV
478
        if !request.is_empty() && !self.force_edit() {
×
UNCOV
479
            return Ok((None, PartialAppConfig::empty()));
×
480
        }
×
481

482
        let editor = match config.editor.command() {
×
483
            None if !request.is_empty() => return Ok((None, PartialAppConfig::empty())),
×
484
            None => return Err(Error::MissingEditor),
×
485
            Some(cmd) => cmd,
×
486
        };
487

488
        let (content, query_file, editor_provided_config) =
×
489
            editor::edit_query(config, root, stream, request.as_str(), editor, None)?;
×
490
        request.content = content;
×
491

492
        Ok((Some(query_file), editor_provided_config))
×
493
    }
×
494

495
    #[expect(clippy::too_many_lines, clippy::too_many_arguments)]
UNCOV
496
    async fn handle_stream(
×
497
        &self,
×
498
        cfg: &AppConfig,
×
499
        signals: &mut SignalRx,
×
500
        mcp_client: &jp_mcp::Client,
×
501
        root: PathBuf,
×
UNCOV
502
        is_tty: bool,
×
503
        turn_state: &mut TurnState,
×
504
        thread: &mut Thread,
×
505
        tool_choice: ToolChoice,
×
506
        tools: Vec<ToolDefinition>,
×
UNCOV
507
        conversation_id: ConversationId,
×
UNCOV
508
    ) -> Result<()> {
×
509
        let mut result = Ok(());
×
510
        let mut cancelled = false;
×
511
        turn_state.request_count += 1;
×
512

513
        let model_id = cfg
×
514
            .assistant
×
515
            .model
×
516
            .id
×
UNCOV
517
            .finalize(&cfg.providers.llm.aliases)?;
×
518

519
        let provider = provider::get_provider(model_id.provider, &cfg.providers.llm)?;
×
UNCOV
520
        let query = ChatQuery {
×
521
            thread: thread.clone(),
×
UNCOV
522

×
523
            // Limit the tools to the ones that are relevant to the tool choice.
×
524
            //
×
525
            // FIXME: This should be done in the individual `Provider`
×
526
            // implementations. This is because some providers support tool
×
527
            // caching, but the cache is busted if the list of tools changes.
×
528
            // Since tools can have elaborate descriptions, this can result in a
×
529
            // significant amount of uncached tokens. For those providers, we
×
530
            // should just trust that `ToolChoice::Function` is handled
×
531
            // correctly by the provider and the correct tool is used, even if
×
532
            // others are available.
×
533
            // tools: match &tool_choice {
×
534
            //     ToolChoice::None => vec![],
×
535
            //     ToolChoice::Auto | ToolChoice::Required => tools.clone(),
×
536
            //     ToolChoice::Function(name) => tools
×
UNCOV
537
            //         .clone()
×
UNCOV
538
            //         .into_iter()
×
539
            //         .filter(|v| &v.name == name)
×
540
            //         .collect(),
×
541
            // },
×
UNCOV
542
            tools: tools.clone(),
×
543
            tool_choice: tool_choice.clone(),
×
UNCOV
544
            tool_call_strict_mode: false,
×
545
        };
×
546
        let model = provider.model_details(&model_id.name).await?;
×
547

UNCOV
548
        info!(
×
549
            model = model
×
UNCOV
550
                .display_name
×
551
                .as_deref()
×
UNCOV
552
                .unwrap_or(&model.id.to_string()),
×
553
            tools = query.tools.iter().map(|v| &v.name).sorted().join(", "),
×
554
            attachments = query
×
UNCOV
555
                .thread
×
UNCOV
556
                .attachments
×
UNCOV
557
                .iter()
×
558
                .map(|v| &v.source)
×
559
                .sorted()
×
UNCOV
560
                .join(", "),
×
UNCOV
561
            "Chat query created."
×
562
        );
563

564
        let mut stream = provider.chat_completion_stream(&model, query).await?;
×
565

UNCOV
566
        let mut event_handler = StreamEventHandler::default();
×
567

568
        let mut printer = ResponseHandler::new(self.render_mode(), cfg.style.tool_call.show);
×
UNCOV
569
        let mut metadata = BTreeMap::new();
×
570

571
        loop {
UNCOV
572
            jp_macro::select!(
×
573
                biased,
574
                signals.recv(),
×
575
                |signal| {
576
                    debug!(?signal, "Received signal.");
×
577
                    match signal {
×
578
                        // Stop processing events, but gracefully store the
579
                        // conversation state.
580
                        Ok(SignalTo::Shutdown) => {
581
                            cancelled = true;
×
582
                            break;
×
583
                        }
584
                        // Immediately stop processing events, and exit, without
585
                        // storing the new conversation state.
586
                        Ok(SignalTo::Quit) => return Ok(()),
×
587
                        Ok(SignalTo::ReloadFromDisk) => {}
×
588
                        Err(error) => error!(?error, "Failed to receive signal."),
×
589
                    }
590
                },
UNCOV
591
                stream.next(),
×
592
                |event| {
UNCOV
593
                    let Some(event) = event else {
×
594
                        break;
×
595
                    };
596

597
                    if let Err(error) = self
×
598
                        .handle_event(
×
UNCOV
599
                            event,
×
UNCOV
600
                            cfg,
×
UNCOV
601
                            mcp_client,
×
UNCOV
602
                            root.clone(),
×
UNCOV
603
                            is_tty,
×
604
                            signals,
×
UNCOV
605
                            turn_state,
×
606
                            provider.as_ref(),
×
607
                            thread,
×
608
                            &tool_choice,
×
609
                            &tools,
×
610
                            &mut printer,
×
611
                            &mut event_handler,
×
612
                            &mut metadata,
×
UNCOV
613
                            conversation_id,
×
614
                        )
UNCOV
615
                        .await
×
616
                    {
UNCOV
617
                        error!(?error, "Received error while handling conversation event.");
×
618
                        cancelled = true;
×
619
                        result = Err(error);
×
620
                        break;
×
621
                    }
×
622
                },
623
            );
624
        }
625

626
        // Ensure we handle the last line of the stream.
627
        printer.drain(&cfg.style, false)?;
×
628

629
        let content_tokens = event_handler.content_tokens.trim().to_string();
×
630
        let content = if !content_tokens.is_empty() {
×
631
            Some(content_tokens)
×
632
        } else if !cancelled && content_tokens.is_empty() && event_handler.tool_calls.is_empty() {
×
633
            let max_tries = 3;
×
634
            if turn_state.request_count <= max_tries {
×
635
                warn!(
×
636
                    turn_state.request_count,
637
                    max_tries, "Empty response received, retrying..."
×
638
                );
639

640
                // Append retry message to the last ChatRequest
UNCOV
641
                if let Some(mut request) = thread.events.last_mut()
×
642
                    && let Some(request) = request.as_chat_request_mut()
×
UNCOV
643
                    && !request.ends_with(EMPTY_RESPONSE_MESSAGE)
×
644
                {
×
UNCOV
645
                    request.push_str(EMPTY_RESPONSE_MESSAGE);
×
646
                }
×
647

UNCOV
648
                return Box::pin(self.handle_stream(
×
649
                    cfg,
×
650
                    signals,
×
651
                    mcp_client,
×
UNCOV
652
                    root,
×
653
                    is_tty,
×
UNCOV
654
                    turn_state,
×
UNCOV
655
                    thread,
×
656
                    tool_choice,
×
657
                    tools,
×
658
                    conversation_id,
×
659
                ))
×
660
                .await;
×
661
            }
×
662

UNCOV
663
            error!(
×
664
                turn_state.request_count,
665
                "Failed to get a non-empty response."
×
666
            );
667
            Some("<no reply>".to_string())
×
668
        } else {
UNCOV
669
            None
×
670
        };
671

672
        let reasoning_tokens = event_handler.reasoning_tokens.trim().to_string();
×
673
        let reasoning = if reasoning_tokens.is_empty() {
×
UNCOV
674
            None
×
675
        } else {
676
            Some(reasoning_tokens)
×
677
        };
678

UNCOV
679
        if let RenderMode::Buffered = printer.render_mode {
×
680
            println!("{}", printer.parsed.join("\n"));
×
681
        } else if content.is_some() || reasoning.is_some() {
×
682
            // Final newline.
×
683
            println!();
×
UNCOV
684
        }
×
685

686
        // Emit reasoning response if present
UNCOV
687
        if let Some(v) = reasoning {
×
UNCOV
688
            thread.events.add_chat_response(ChatResponse::reasoning(v));
×
689

UNCOV
690
            if let Some(mut event) = thread.events.last_mut() {
×
691
                event.metadata.extend(metadata);
×
692
            }
×
UNCOV
693
        }
×
694

695
        // Emit message response if present
696
        if let Some(v) = content {
×
697
            thread.events.add_chat_response(ChatResponse::message(v));
×
698
        }
×
699

700
        // Emit tool call request events.
701
        for tool_call in event_handler.tool_calls {
×
702
            thread.events.add_tool_call_request(tool_call);
×
703
        }
×
704

705
        let has_tool_call_responses = !event_handler.tool_call_responses.is_empty();
×
706
        for response in event_handler.tool_call_responses {
×
707
            thread.events.add_tool_call_response(response);
×
708
        }
×
709

710
        // If a cancellation was requested, we DO NOT deliver the responses
711
        // to the assistant. We DO store the responses to disk, such that a
712
        // new invocation of the CLI picks up where we left off.
713
        //
714
        // If not cancelled, we deliver the tool call results to the
715
        // assistant in a loop. Rebuild thread with all events so far.
716
        if !cancelled && has_tool_call_responses {
×
717
            turn_state.request_count = 0;
×
718

719
            Box::pin(self.handle_stream(
×
720
                cfg,
×
721
                signals,
×
722
                mcp_client,
×
723
                root,
×
724
                is_tty,
×
725
                turn_state,
×
726
                thread,
×
727
                // After the first tool call, we revert back to letting the LLM
×
728
                // decide if/which tool to use.
×
729
                ToolChoice::Auto,
×
730
                tools,
×
731
                conversation_id,
×
732
            ))
×
733
            .await?;
×
734
        }
×
735

736
        result
×
737
    }
×
738

739
    #[expect(clippy::too_many_arguments)]
740
    async fn handle_event(
×
UNCOV
741
        &self,
×
742
        event: std::result::Result<Event, jp_llm::Error>,
×
743
        cfg: &AppConfig,
×
744
        mcp_client: &jp_mcp::Client,
×
745
        root: PathBuf,
×
UNCOV
746
        is_tty: bool,
×
747
        signals: &mut SignalRx,
×
748
        turn_state: &mut TurnState,
×
749
        provider: &dyn provider::Provider,
×
750
        thread: &mut Thread,
×
751
        tool_choice: &ToolChoice,
×
752
        tools: &[ToolDefinition],
×
753
        printer: &mut ResponseHandler,
×
754
        event_handler: &mut StreamEventHandler,
×
755
        metadata: &mut BTreeMap<String, Value>,
×
756
        conversation_id: ConversationId,
×
757
    ) -> Result<()> {
×
758
        let tries = turn_state.request_count;
×
759
        let event = match event {
×
760
            Err(jp_llm::Error::RateLimit { retry_after }) => {
×
UNCOV
761
                let max_tries = 5;
×
762
                if tries > max_tries {
×
763
                    error!(tries, "Failed to get a non-rate-limited response.");
×
764
                    return Err(Error::Llm(jp_llm::Error::RateLimit { retry_after: None }));
×
765
                }
×
766

767
                let retry_after = retry_after.unwrap_or(Duration::from_secs(2));
×
768
                warn!(
×
UNCOV
769
                    retry_after_secs = retry_after.as_secs(),
×
770
                    tries, max_tries, "Rate limited, retrying..."
×
771
                );
772
                tokio::time::sleep(retry_after).await;
×
773
                return Box::pin(self.handle_stream(
×
UNCOV
774
                    cfg,
×
775
                    signals,
×
UNCOV
776
                    mcp_client,
×
UNCOV
777
                    root,
×
778
                    is_tty,
×
779
                    turn_state,
×
780
                    thread,
×
UNCOV
781
                    tool_choice.clone(),
×
782
                    tools.to_vec(),
×
783
                    conversation_id,
×
784
                ))
×
785
                .await;
×
786
            }
787
            Err(jp_llm::Error::UnknownModel(model)) => {
×
788
                let available = provider
×
789
                    .models()
×
UNCOV
790
                    .await?
×
791
                    .into_iter()
×
UNCOV
792
                    .map(|v| v.id.name.to_string())
×
UNCOV
793
                    .collect();
×
794

795
                return Err(Error::UnknownModel { model, available });
×
796
            }
UNCOV
797
            Err(e) => {
×
798
                return Err(e.into());
×
799
            }
800
            Ok(event) => event,
×
801
        };
802

803
        let data = match event {
×
804
            Event::Part { event, .. } => {
×
805
                let ConversationEvent {
806
                    kind, metadata: m, ..
×
807
                } = event;
×
808
                metadata.extend(m);
×
809

810
                match kind {
×
811
                    EventKind::ChatResponse(response) => {
×
UNCOV
812
                        event_handler.handle_chat_chunk(cfg.style.reasoning.display, response)
×
813
                    }
UNCOV
814
                    EventKind::ToolCallRequest(request) => {
×
UNCOV
815
                        event_handler
×
UNCOV
816
                            .handle_tool_call(
×
UNCOV
817
                                cfg, mcp_client, root, is_tty, turn_state, request, printer,
×
UNCOV
818
                            )
×
UNCOV
819
                            .await?
×
820
                    }
821
                    EventKind::ChatRequest(_) => panic!("invalid part `ChatRequest` received"),
×
822
                    EventKind::ToolCallResponse(_) => {
UNCOV
823
                        panic!("invalid part `ToolCallResponse` received")
×
824
                    }
UNCOV
825
                    _ => todo!("handle `inquery` events"),
×
826
                }
827
            }
UNCOV
828
            Event::Flush { .. } => None,
×
829
            Event::Finished(_) => return Ok(()),
×
830
        };
831

UNCOV
832
        let Some(data) = data else {
×
UNCOV
833
            return Ok(());
×
834
        };
835

836
        printer.handle(&data, &cfg.style, false)?;
×
837

838
        Ok(())
×
839
    }
×
840

841
    fn render_mode(&self) -> RenderMode {
×
UNCOV
842
        if self.no_stream {
×
843
            return RenderMode::Buffered;
×
844
        } else if self.stream {
×
845
            return RenderMode::Streamed;
×
846
        }
×
847

848
        RenderMode::Auto
×
849
    }
×
850

851
    /// Returns `true` if editing is explicitly disabled.
852
    ///
853
    /// This signals that even if no query is provided, no editor should be
854
    /// opened, but instead an empty query should be used.
855
    ///
856
    /// This can be used for example when requesting a tool call without needing
857
    /// additional context to be provided.
UNCOV
858
    fn force_no_edit(&self) -> bool {
×
UNCOV
859
        self.no_edit || matches!(self.edit, Some(Some(Editor::Disabled)))
×
UNCOV
860
    }
×
861

862
    /// Returns `true` if editing is explicitly enabled.
863
    ///
864
    /// This means the `--edit` flag was provided (but not `--edit=false`),
865
    /// which means the editor should be opened, regardless of whether a query
866
    /// is provided as an argument.
UNCOV
867
    fn force_edit(&self) -> bool {
×
UNCOV
868
        !self.force_no_edit() && self.edit.is_some()
×
UNCOV
869
    }
×
870
}
871

UNCOV
872
fn get_config_delta_from_cli(
×
UNCOV
873
    cfg: &AppConfig,
×
UNCOV
874
    ws: &Workspace,
×
UNCOV
875
    conversation_id: &ConversationId,
×
UNCOV
876
) -> Result<Option<PartialAppConfig>> {
×
UNCOV
877
    let partial = ws
×
UNCOV
878
        .get_events(conversation_id)
×
UNCOV
879
        .map_or_else(
×
UNCOV
880
            || Ok(PartialAppConfig::empty()),
×
UNCOV
881
            |stream| stream.config().map(|c| c.to_partial()),
×
882
        )
UNCOV
883
        .map_err(jp_conversation::Error::from)?;
×
884

UNCOV
885
    let partial = partial.delta(cfg.to_partial());
×
UNCOV
886
    if partial.is_empty() {
×
UNCOV
887
        return Ok(None);
×
UNCOV
888
    }
×
889

UNCOV
890
    Ok(Some(partial))
×
UNCOV
891
}
×
892

893
impl IntoPartialAppConfig for Query {
894
    fn apply_cli_config(
6✔
895
        &self,
6✔
896
        _workspace: Option<&Workspace>,
6✔
897
        mut partial: PartialAppConfig,
6✔
898
        merged_config: Option<&PartialAppConfig>,
6✔
899
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
6✔
900
        let Self {
901
            model,
6✔
902
            template: _,
903
            schema: _,
904
            replay: _,
905
            new_conversation: _,
906
            local: _,
907
            attachments,
6✔
908
            edit,
6✔
909
            no_edit,
6✔
910
            tool_use,
6✔
911
            no_tool_use,
6✔
912
            query: _,
913
            parameters,
6✔
914
            hide_reasoning,
6✔
915
            hide_tool_calls,
6✔
916
            stream: _,
917
            no_stream: _,
918
            tools,
6✔
919
            no_tools,
6✔
920
            reasoning,
6✔
921
            no_reasoning,
6✔
922
        } = &self;
6✔
923

924
        apply_model(&mut partial, model.as_deref(), merged_config);
6✔
925
        apply_editor(&mut partial, edit.as_ref().map(|v| v.as_ref()), *no_edit);
6✔
926
        apply_enable_tools(&mut partial, tools, no_tools, merged_config)?;
6✔
927
        apply_tool_use(
6✔
928
            &mut partial,
6✔
929
            tool_use.as_ref().map(|v| v.as_deref()),
6✔
930
            *no_tool_use,
6✔
UNCOV
931
        )?;
×
932
        apply_attachments(&mut partial, attachments);
6✔
933
        apply_reasoning(&mut partial, reasoning.as_ref(), *no_reasoning);
6✔
934

935
        for kv in parameters.clone() {
6✔
936
            partial.assistant.model.parameters.assign(kv)?;
×
937
        }
938

939
        if *hide_reasoning {
6✔
940
            partial.style.reasoning.display = Some(ReasoningDisplayConfig::Hidden);
×
941
        }
6✔
942

943
        if *hide_tool_calls {
6✔
944
            partial.style.tool_call.show = Some(false);
×
945
        }
6✔
946

947
        Ok(partial)
6✔
948
    }
6✔
949

UNCOV
950
    fn apply_conversation_config(
×
951
        &self,
×
952
        workspace: Option<&Workspace>,
×
953
        partial: PartialAppConfig,
×
954
        _: Option<&PartialAppConfig>,
×
955
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
956
        // New conversations do not apply any existing conversation
957
        // configurations. This is handled by the other configuration layers
958
        // (files, environment variables, CLI arguments).
959
        if self.new_conversation {
×
960
            return Ok(partial);
×
961
        }
×
962

963
        // If we're not inside a workspace, there is no active conversation to
964
        // fetch the configuration from.
965
        let Some(workspace) = workspace else {
×
966
            return Ok(partial);
×
967
        };
968

UNCOV
969
        let id = workspace.active_conversation_id();
×
UNCOV
970
        let config = workspace.get_events(&id).map_or_else(
×
UNCOV
971
            || Ok(PartialAppConfig::empty()),
×
972
            |stream| stream.config().map(|c| c.to_partial()),
×
UNCOV
973
        )?;
×
974

UNCOV
975
        load_partial(partial, config).map_err(Into::into)
×
UNCOV
976
    }
×
977
}
978

UNCOV
979
fn build_thread(
×
UNCOV
980
    events: ConversationStream,
×
981
    attachments: Vec<Attachment>,
×
982
    assistant: &AssistantConfig,
×
983
    tools: &[ToolDefinition],
×
984
) -> Result<Thread> {
×
985
    let mut thread_builder = ThreadBuilder::default()
×
986
        .with_instructions(assistant.instructions.to_vec())
×
987
        .with_attachments(attachments)
×
UNCOV
988
        .with_events(events);
×
989

UNCOV
990
    if let Some(system_prompt) = assistant.system_prompt.clone() {
×
UNCOV
991
        thread_builder = thread_builder.with_system_prompt(system_prompt);
×
UNCOV
992
    }
×
993

UNCOV
994
    if !tools.is_empty() {
×
UNCOV
995
        let instruction = InstructionsConfig::default()
×
UNCOV
996
            .with_title("Tool Usage")
×
UNCOV
997
            .with_description("How to leverage the tools available to you.".to_string())
×
UNCOV
998
            .with_item("Use all the tools available to you to give the best possible answer.")
×
UNCOV
999
            .with_item("Verify the tool name, description and parameters are correct.")
×
UNCOV
1000
            .with_item(
×
UNCOV
1001
                "Even if you've reasoned yourself towards a solution, use any available tool to \
×
UNCOV
1002
                 verify your answer.",
×
UNCOV
1003
            );
×
UNCOV
1004

×
UNCOV
1005
        thread_builder = thread_builder.add_instruction(instruction);
×
UNCOV
1006
    }
×
1007

UNCOV
1008
    Ok(thread_builder.build()?)
×
UNCOV
1009
}
×
1010

1011
/// Apply the CLI model configuration to the partial configuration.
1012
fn apply_model(partial: &mut PartialAppConfig, model: Option<&str>, _: Option<&PartialAppConfig>) {
6✔
1013
    let Some(id) = model else { return };
6✔
1014

UNCOV
1015
    partial.assistant.model.id = id.into();
×
1016
}
6✔
1017

1018
/// Apply the CLI editor configuration to the partial configuration.
1019
fn apply_editor(partial: &mut PartialAppConfig, editor: Option<Option<&Editor>>, no_edit: bool) {
6✔
UNCOV
1020
    let Some(Some(editor)) = editor else {
×
1021
        return;
6✔
1022
    };
1023

UNCOV
1024
    match (no_edit, editor) {
×
UNCOV
1025
        (true, _) | (_, Editor::Disabled) => {
×
UNCOV
1026
            partial.editor.cmd = None;
×
UNCOV
1027
            partial.editor.envs = None;
×
UNCOV
1028
        }
×
UNCOV
1029
        (_, Editor::Default) => {}
×
UNCOV
1030
        (_, Editor::Command(cmd)) => partial.editor.cmd = Some(cmd.clone()),
×
1031
    }
1032
}
6✔
1033

1034
fn apply_enable_tools(
6✔
1035
    partial: &mut PartialAppConfig,
6✔
1036
    tools: &[Option<String>],
6✔
1037
    no_tools: &[Option<String>],
6✔
1038
    merged_config: Option<&PartialAppConfig>,
6✔
1039
) -> BoxedResult<()> {
6✔
1040
    let tools = if tools.is_empty() {
6✔
1041
        None
3✔
1042
    } else if tools.iter().any(Option::is_none) {
3✔
1043
        Some(vec![])
1✔
1044
    } else {
1045
        Some(tools.iter().filter_map(|v| v.as_deref()).collect())
3✔
1046
    };
1047

1048
    let no_tools = if no_tools.is_empty() {
6✔
1049
        None
4✔
1050
    } else if no_tools.iter().any(Option::is_none) {
2✔
1051
        Some(vec![])
1✔
1052
    } else {
1053
        Some(no_tools.iter().filter_map(|v| v.as_deref()).collect())
1✔
1054
    };
1055

1056
    let enable_all = tools.as_ref().is_some_and(Vec::is_empty);
6✔
1057
    let disable_all = no_tools.as_ref().is_some_and(Vec::is_empty);
6✔
1058

1059
    if enable_all && disable_all {
6✔
UNCOV
1060
        return Err("cannot pass both --no-tools and --tools without arguments".into());
×
1061
    }
6✔
1062

1063
    let existing_tools = merged_config.map_or(&partial.conversation.tools.tools, |v| {
6✔
UNCOV
1064
        &v.conversation.tools.tools
×
UNCOV
1065
    });
×
1066

1067
    let missing = tools
6✔
1068
        .iter()
6✔
1069
        .flatten()
6✔
1070
        .chain(no_tools.iter().flatten())
6✔
1071
        .filter(|name| !existing_tools.contains_key(**name))
6✔
1072
        .collect::<HashSet<_>>();
6✔
1073

1074
    if missing.len() == 1 {
6✔
UNCOV
1075
        return Err(ToolError::NotFound {
×
UNCOV
1076
            name: missing.iter().next().unwrap().to_string(),
×
UNCOV
1077
        }
×
UNCOV
1078
        .into());
×
1079
    } else if !missing.is_empty() {
6✔
UNCOV
1080
        return Err(ToolError::NotFoundN {
×
UNCOV
1081
            names: missing.into_iter().map(ToString::to_string).collect(),
×
UNCOV
1082
        }
×
UNCOV
1083
        .into());
×
1084
    }
6✔
1085

1086
    // Disable all first, if all tools are to be disabled.
1087
    if disable_all {
6✔
1088
        partial
1✔
1089
            .conversation
1✔
1090
            .tools
1✔
1091
            .tools
1✔
1092
            .iter_mut()
1✔
1093
            .for_each(|(_, v)| v.enable = Some(false));
3✔
1094
    // Enable all tools first if all tools are to be enabled.
1095
    } else if enable_all {
5✔
1096
        partial
1✔
1097
            .conversation
1✔
1098
            .tools
1✔
1099
            .tools
1✔
1100
            .iter_mut()
1✔
1101
            .for_each(|(_, v)| v.enable = Some(true));
3✔
1102
    }
4✔
1103

1104
    // Then enable individual tools.
1105
    if let Some(tools) = tools {
6✔
1106
        partial
3✔
1107
            .conversation
3✔
1108
            .tools
3✔
1109
            .tools
3✔
1110
            .iter_mut()
3✔
1111
            .filter(|(name, _)| tools.iter().any(|v| v == *name))
9✔
1112
            .for_each(|(_, v)| v.enable = Some(true));
3✔
1113
    }
3✔
1114

1115
    // And finally disable individual tools.
1116
    if let Some(no_tools) = no_tools {
6✔
1117
        partial
2✔
1118
            .conversation
2✔
1119
            .tools
2✔
1120
            .tools
2✔
1121
            .iter_mut()
2✔
1122
            .filter(|(name, _)| no_tools.iter().any(|v| v == name))
6✔
1123
            .for_each(|(_, v)| v.enable = Some(false));
2✔
1124
    }
4✔
1125

1126
    Ok(())
6✔
1127
}
6✔
1128

1129
/// Apply the CLI tool use configuration to the partial configuration.
1130
///
1131
/// NOTE: This has to run *after* `apply_enable_tools` because it will return an
1132
/// error if the tool of choice is not enabled.
1133
fn apply_tool_use(
6✔
1134
    partial: &mut PartialAppConfig,
6✔
1135
    tool_choice: Option<Option<&str>>,
6✔
1136
    no_tool_choice: bool,
6✔
1137
) -> BoxedResult<()> {
6✔
1138
    if no_tool_choice || matches!(tool_choice, Some(Some("false"))) {
6✔
UNCOV
1139
        partial.assistant.tool_choice = Some(ToolChoice::None);
×
UNCOV
1140
        return Ok(());
×
1141
    }
6✔
1142

1143
    let Some(tool) = tool_choice else {
6✔
1144
        return Ok(());
6✔
1145
    };
1146

UNCOV
1147
    partial.assistant.tool_choice = match tool {
×
UNCOV
1148
        None | Some("true") => Some(ToolChoice::Required),
×
UNCOV
1149
        Some(v) => {
×
UNCOV
1150
            if !partial
×
UNCOV
1151
                .conversation
×
1152
                .tools
×
1153
                .tools
×
1154
                .iter()
×
1155
                .filter(|(_, cfg)| cfg.enable.is_some_and(|v| v))
×
1156
                .any(|(name, _)| name == v)
×
1157
            {
1158
                return Err(format!("tool choice '{v}' does not match any enabled tools").into());
×
1159
            }
×
1160

UNCOV
1161
            Some(ToolChoice::Function(v.to_owned()))
×
1162
        }
1163
    };
1164

1165
    Ok(())
×
1166
}
6✔
1167

1168
/// Apply the CLI attachments to the partial configuration.
1169
fn apply_attachments(partial: &mut PartialAppConfig, attachments: &[Url]) {
6✔
1170
    if attachments.is_empty() {
6✔
1171
        return;
6✔
1172
    }
×
1173

1174
    partial
×
1175
        .conversation
×
1176
        .attachments
×
UNCOV
1177
        .extend(attachments.iter().cloned().map(Into::into));
×
1178
}
6✔
1179

1180
/// Apply the CLI reasoning configuration to the partial configuration.
1181
fn apply_reasoning(
6✔
1182
    partial: &mut PartialAppConfig,
6✔
1183
    reasoning: Option<&ReasoningConfig>,
6✔
1184
    no_reasoning: bool,
6✔
1185
) {
6✔
1186
    if no_reasoning {
6✔
1187
        partial.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Off);
×
1188
        return;
×
1189
    }
6✔
1190

1191
    let Some(reasoning) = reasoning else {
6✔
1192
        return;
6✔
1193
    };
1194

1195
    partial.assistant.model.parameters.reasoning = Some(match reasoning {
×
1196
        ReasoningConfig::Off => PartialReasoningConfig::Off,
×
1197
        ReasoningConfig::Auto => PartialReasoningConfig::Auto,
×
1198
        ReasoningConfig::Custom(custom) => PartialCustomReasoningConfig {
×
1199
            effort: Some(custom.effort),
×
1200
            exclude: Some(custom.exclude),
×
UNCOV
1201
        }
×
1202
        .into(),
×
1203
    });
1204
}
6✔
1205

1206
/// Clean up empty queries.
1207
fn cleanup(
×
1208
    ctx: &mut Ctx,
×
1209
    last_active_conversation_id: ConversationId,
×
UNCOV
1210
    query_file_path: Option<&Path>,
×
1211
) -> Result<Success> {
×
1212
    let conversation_id = ctx.workspace.active_conversation_id();
×
1213

1214
    info!("Query is empty, exiting.");
×
UNCOV
1215
    if last_active_conversation_id != conversation_id {
×
UNCOV
1216
        ctx.workspace
×
1217
            .set_active_conversation_id(last_active_conversation_id)?;
×
1218
        ctx.workspace.remove_conversation(&conversation_id)?;
×
1219
    }
×
1220

UNCOV
1221
    if let Some(path) = query_file_path {
×
1222
        fs::remove_file(path)?;
×
1223
    }
×
1224

UNCOV
1225
    Ok("Query is empty, ignoring.".into())
×
1226
}
×
1227

1228
async fn handle_structured_output(
×
1229
    cfg: &AppConfig,
×
1230
    is_tty: bool,
×
UNCOV
1231
    thread: &mut Thread,
×
1232
    schema: schemars::Schema,
×
1233
    render_mode: RenderMode,
×
1234
) -> Output {
×
1235
    let model_id = cfg
×
UNCOV
1236
        .assistant
×
1237
        .model
×
1238
        .id
×
UNCOV
1239
        .finalize(&cfg.providers.llm.aliases)?;
×
1240
    let provider = provider::get_provider(model_id.provider, &cfg.providers.llm)?;
×
1241
    let query = StructuredQuery::new(schema, thread.clone());
×
UNCOV
1242
    let model = provider.model_details(&model_id.name).await?;
×
1243

UNCOV
1244
    let result = provider.structured_completion(&model, query).await?;
×
1245

UNCOV
1246
    let content = serde_json::to_string(&result)?;
×
UNCOV
1247
    thread
×
UNCOV
1248
        .events
×
UNCOV
1249
        .add_chat_response(ChatResponse::message(&content));
×
1250

UNCOV
1251
    let content = if is_tty {
×
UNCOV
1252
        serde_json::to_string_pretty(&result)?
×
1253
    } else {
UNCOV
1254
        content
×
1255
    };
1256

UNCOV
1257
    if render_mode.is_streamed() {
×
1258
        stdout::typewriter(&content, cfg.style.typewriter.code_delay.into())?;
×
1259
        return Ok(Success::Ok);
×
1260
    }
×
1261

1262
    Ok(Success::Json(result))
×
UNCOV
1263
}
×
1264

1265
#[expect(clippy::needless_pass_by_value)]
1266
fn json_schema(s: String) -> Result<schemars::Schema> {
×
1267
    serde_json::from_str::<serde_json::Value>(&s)?
×
1268
        .try_into()
×
1269
        .map_err(Into::into)
×
1270
}
×
1271

1272
fn string_or_path(s: &str) -> Result<String> {
×
1273
    if let Some(s) = s
×
UNCOV
1274
        .strip_prefix(PATH_STRING_PREFIX)
×
1275
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
1276
    {
UNCOV
1277
        return fs::read_to_string(s).map_err(Into::into);
×
1278
    }
×
1279

1280
    Ok(s.to_owned())
×
UNCOV
1281
}
×
1282

1283
struct Line {
1284
    content: String,
1285
    variant: LineVariant,
1286
}
1287

1288
#[derive(Debug)]
1289
enum LineVariant {
1290
    Normal,
1291
    Code,
1292
    Raw,
1293
    FencedCodeBlockStart { language: Option<String> },
1294
    FencedCodeBlockEnd { indent: usize },
1295
}
1296

1297
impl Line {
UNCOV
1298
    fn new(content: String, in_fenced_code_block: bool, raw: bool) -> Self {
×
UNCOV
1299
        let variant = if raw {
×
UNCOV
1300
            LineVariant::Raw
×
UNCOV
1301
        } else if in_fenced_code_block && content.trim().ends_with("```") {
×
UNCOV
1302
            let indent = content.chars().take_while(|c| c.is_whitespace()).count();
×
1303

UNCOV
1304
            LineVariant::FencedCodeBlockEnd { indent }
×
UNCOV
1305
        } else if content.trim_start().starts_with("```") {
×
UNCOV
1306
            let language = content
×
UNCOV
1307
                .trim_start()
×
UNCOV
1308
                .chars()
×
UNCOV
1309
                .skip(3)
×
UNCOV
1310
                .take_while(|c| c.is_alphanumeric())
×
UNCOV
1311
                .collect::<String>();
×
UNCOV
1312
            let language = if language.is_empty() {
×
UNCOV
1313
                None
×
1314
            } else {
UNCOV
1315
                Some(language)
×
1316
            };
1317

UNCOV
1318
            LineVariant::FencedCodeBlockStart { language }
×
UNCOV
1319
        } else if in_fenced_code_block {
×
UNCOV
1320
            LineVariant::Code
×
1321
        } else {
UNCOV
1322
            LineVariant::Normal
×
1323
        };
1324

UNCOV
1325
        Line { content, variant }
×
UNCOV
1326
    }
×
1327
}
1328

1329
#[cfg(test)]
1330
mod tests {
1331
    use indexmap::IndexMap;
1332
    use jp_config::conversation::tool::PartialToolConfig;
1333

1334
    use super::*;
1335

1336
    #[test]
1337
    #[expect(clippy::too_many_lines)]
1338
    fn test_query_tools_and_no_tools() {
1✔
1339
        // Create a partial configuration with a few tools.
1340
        let mut partial = PartialAppConfig::default();
1✔
1341
        partial.conversation.tools.tools = IndexMap::from_iter([
1✔
1342
            ("implicitly_enabled_tool".into(), PartialToolConfig {
1✔
1343
                enable: None,
1✔
1344
                ..Default::default()
1✔
1345
            }),
1✔
1346
            ("explicitly_enabled_tool".into(), PartialToolConfig {
1✔
1347
                enable: Some(true),
1✔
1348
                ..Default::default()
1✔
1349
            }),
1✔
1350
            ("explicitly_disabled_tool".into(), PartialToolConfig {
1✔
1351
                enable: Some(false),
1✔
1352
                ..Default::default()
1✔
1353
            }),
1✔
1354
        ]);
1✔
1355

1356
        // Keep all tools as-is.
1357
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1358
            &Query {
1✔
1359
                no_tools: vec![],
1✔
1360
                ..Default::default()
1✔
1361
            },
1✔
1362
            None,
1✔
1363
            partial,
1✔
1364
            None,
1✔
1365
        )
1366
        .unwrap();
1✔
1367

1368
        assert_eq!(
1✔
1369
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1370
            None,
1371
        );
1372
        assert_eq!(
1✔
1373
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1374
            Some(true)
1375
        );
1376
        assert_eq!(
1✔
1377
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1378
            Some(false)
1379
        );
1380

1381
        // Disable one tool.
1382
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1383
            &Query {
1✔
1384
                no_tools: vec![Some("implicitly_enabled_tool".into())],
1✔
1385
                ..Default::default()
1✔
1386
            },
1✔
1387
            None,
1✔
1388
            partial,
1✔
1389
            None,
1✔
1390
        )
1391
        .unwrap();
1✔
1392

1393
        assert_eq!(
1✔
1394
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1395
            Some(false),
1396
        );
1397
        assert_eq!(
1✔
1398
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1399
            Some(true)
1400
        );
1401
        assert_eq!(
1✔
1402
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1403
            Some(false)
1404
        );
1405

1406
        // Enable one tool.
1407
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1408
            &Query {
1✔
1409
                tools: vec![Some("explicitly_disabled_tool".into())],
1✔
1410
                ..Default::default()
1✔
1411
            },
1✔
1412
            None,
1✔
1413
            partial,
1✔
1414
            None,
1✔
1415
        )
1416
        .unwrap();
1✔
1417

1418
        assert_eq!(
1✔
1419
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1420
            Some(false),
1421
        );
1422
        assert_eq!(
1✔
1423
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1424
            Some(true)
1425
        );
1426
        assert_eq!(
1✔
1427
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1428
            Some(true)
1429
        );
1430

1431
        // Enable all tools.
1432
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1433
            &Query {
1✔
1434
                tools: vec![None],
1✔
1435
                ..Default::default()
1✔
1436
            },
1✔
1437
            None,
1✔
1438
            partial,
1✔
1439
            None,
1✔
1440
        )
1441
        .unwrap();
1✔
1442

1443
        assert_eq!(
1✔
1444
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1445
            Some(true),
1446
        );
1447
        assert_eq!(
1✔
1448
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1449
            Some(true)
1450
        );
1451
        assert_eq!(
1✔
1452
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1453
            Some(true)
1454
        );
1455

1456
        // Disable all tools.
1457
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1458
            &Query {
1✔
1459
                no_tools: vec![None],
1✔
1460
                ..Default::default()
1✔
1461
            },
1✔
1462
            None,
1✔
1463
            partial,
1✔
1464
            None,
1✔
1465
        )
1466
        .unwrap();
1✔
1467

1468
        assert_eq!(
1✔
1469
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1470
            Some(false),
1471
        );
1472
        assert_eq!(
1✔
1473
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1474
            Some(false)
1475
        );
1476
        assert_eq!(
1✔
1477
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1478
            Some(false)
1479
        );
1480

1481
        // Enable multiple tools.
1482
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1483
            &Query {
1✔
1484
                tools: vec![
1✔
1485
                    Some("explicitly_disabled_tool".into()),
1✔
1486
                    Some("explicitly_enabled_tool".into()),
1✔
1487
                ],
1✔
1488
                ..Default::default()
1✔
1489
            },
1✔
1490
            None,
1✔
1491
            partial,
1✔
1492
            None,
1✔
1493
        )
1494
        .unwrap();
1✔
1495

1496
        assert_eq!(
1✔
1497
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1498
            Some(false),
1499
        );
1500
        assert_eq!(
1✔
1501
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1502
            Some(true)
1503
        );
1504
        assert_eq!(
1✔
1505
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1506
            Some(true)
1507
        );
1508
    }
1✔
1509
}
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