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

dcdpr / jp / 18194738131

02 Oct 2025 01:38PM UTC coverage: 44.217% (-0.02%) from 44.232%
18194738131

push

github

web-flow
feat(config): Add model alias support to simplify model configuration (#264)

Users can now specify model aliases instead of full model IDs when
configuring models. The system supports both direct model IDs like
`anthropic/claude-3-5-sonnet` and simple aliases like `claude` or
`sonnet` that are resolved through the `providers.llm.aliases`
configuration.

This change introduces `ModelIdOrAliasConfig` enum that can hold either
a concrete `ModelIdConfig` or a string alias. The `finalize()` method
resolves aliases to concrete model IDs by looking them up in the
`aliases` configuration, falling back to parsing as a direct model ID if
no alias is found.

The CLI already supported referencing models by their alias, but now you
can do the same in configuration files.

---------

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

58 of 118 new or added lines in 10 files covered. (49.15%)

1 existing line in 1 file now uncovered.

6182 of 13981 relevant lines covered (44.22%)

5.59 hits per line

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

30.71
/crates/jp_cli/src/cmd/query.rs
1
mod event;
2
mod response_handler;
3

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

12
use clap::{builder::TypedValueParser as _, ArgAction};
13
use event::{handle_tool_calls, StreamEventHandler};
14
use futures::StreamExt as _;
15
use jp_attachment::Attachment;
16
use jp_config::{
17
    assignment::{AssignKeyValue as _, KvAssignment},
18
    assistant::{instructions::InstructionsConfig, tool_choice::ToolChoice, AssistantConfig},
19
    fs::{expand_tilde, load_partial},
20
    model::parameters::{PartialCustomReasoningConfig, PartialReasoningConfig, ReasoningConfig},
21
    PartialAppConfig,
22
};
23
use jp_conversation::{
24
    message::Messages,
25
    thread::{Thread, ThreadBuilder},
26
    AssistantMessage, Conversation, ConversationId, MessagePair, UserMessage,
27
};
28
use jp_llm::{
29
    provider::{self, StreamEvent},
30
    query::{ChatQuery, StructuredQuery},
31
    tool::{tool_definitions, ToolDefinition},
32
    ToolError,
33
};
34
use jp_task::task::TitleGeneratorTask;
35
use jp_term::stdout;
36
use jp_workspace::Workspace;
37
use minijinja::{Environment, UndefinedBehavior};
38
use response_handler::ResponseHandler;
39
use tracing::{debug, error, info, trace, warn};
40
use url::Url;
41

42
use super::{attachment::register_attachment, Output};
43
use crate::{
44
    cmd::Success,
45
    ctx::IntoPartialAppConfig,
46
    editor::{self, Editor},
47
    error::{Error, Result},
48
    load_cli_cfg_args, parser, Ctx, PATH_STRING_PREFIX,
49
};
50

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

53
#[derive(Debug, Default, clap::Args)]
54
pub(crate) struct Query {
55
    /// The query to send. If not provided, uses `$JP_EDITOR`, `$VISUAL` or
56
    /// `$EDITOR` to open edit the query in an editor.
57
    #[arg(value_parser = string_or_path)]
58
    query: Option<Vec<String>>,
59

60
    /// Use the query string as a Jinja2 template.
61
    ///
62
    /// You can provide values for template variables using the
63
    /// `template.values` config key.
64
    #[arg(long)]
65
    template: bool,
66

67
    #[arg(long, value_parser = string_or_path.try_map(json_schema))]
68
    schema: Option<schemars::Schema>,
69

70
    /// Replay the last message in the conversation.
71
    ///
72
    /// If a query is provided, it will be appended to the end of the previous
73
    /// message. If no query is provided, $EDITOR will open with the last
74
    /// message in the conversation.
75
    #[arg(long = "replay", conflicts_with = "new_conversation")]
76
    replay: bool,
77

78
    /// Start a new conversation without any message history.
79
    #[arg(short = 'n', long = "new")]
80
    new_conversation: bool,
81

82
    /// Store the conversation locally, outside of the workspace.
83
    #[arg(short = 'l', long = "local", requires = "new_conversation")]
84
    local: bool,
85

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

90
    /// Whether and how to edit the query.
91
    ///
92
    /// Setting this flag to `true`, omitting it, or using it as a boolean flag
93
    /// (e.g. `--edit`) will use the default editor configured elsewhere, or
94
    /// return an error if no editor is configured and one is required.
95
    ///
96
    /// If set to `false`, the editor will be disabled (similar to `--no-edit`),
97
    /// which might result in an error if the editor is required.
98
    ///
99
    /// If set to any other value, it will be used as the command to open the
100
    /// editor.
101
    #[arg(short = 'e', long = "edit", conflicts_with = "no_edit")]
102
    edit: Option<Option<Editor>>,
103

104
    /// Do not edit the query.
105
    ///
106
    /// See `--edit` for more details.
107
    #[arg(short = 'E', long = "no-edit", conflicts_with = "edit")]
108
    no_edit: bool,
109

110
    /// The model to use.
111
    #[arg(short = 'o', long = "model")]
112
    model: Option<String>,
113

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

118
    /// Enable reasoning.
119
    #[arg(short = 'r', long = "reasoning")]
120
    reasoning: Option<ReasoningConfig>,
121

122
    /// Disable reasoning.
123
    #[arg(short = 'R', long = "no-reasoning")]
124
    no_reasoning: bool,
125

126
    /// Do not display the reasoning content.
127
    ///
128
    /// This does not stop the assistant from generating reasoning tokens to
129
    /// help with its accuracy, but it does not display them in the output.
130
    #[arg(long = "hide-reasoning")]
131
    hide_reasoning: bool,
132

133
    /// Do not display tool calls.
134
    ///
135
    /// This does not stop the assistant from running tool calls, but it does
136
    /// not display them in the output.
137
    #[arg(long = "hide-tool-calls")]
138
    hide_tool_calls: bool,
139

140
    /// Stream the assistant's response as it is generated.
141
    ///
142
    /// This is the default behaviour for TTY sessions, but can be forced for
143
    /// non-TTY sessions by setting this flag.
144
    #[arg(short = 's', long = "stream", conflicts_with = "no_stream")]
145
    stream: bool,
146

147
    /// Disable streaming the assistant's response.
148
    ///
149
    /// This is the default behaviour for non-TTY sessions, or for structured
150
    /// responses, but can be forced by setting this flag.
151
    #[arg(short = 'S', long = "no-stream", conflicts_with = "stream")]
152
    no_stream: bool,
153

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

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

194
    /// The tool to use.
195
    ///
196
    /// If a value is provided, the tool matching the value will be used.
197
    ///
198
    /// Note that this setting is *not* persisted across queries. To persist
199
    /// tool choice behavior, set the `assistant.tool_choice` field in a
200
    /// configuration file.
201
    #[arg(short = 'u', long = "tool-use")]
202
    tool_use: Option<Option<String>>,
203

204
    /// Disable tool use by the assistant.
205
    #[arg(short = 'U', long = "no-tool-use")]
206
    no_tool_use: bool,
207
}
208

209
/// How to render the response to the user.
210
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
211
pub(crate) enum RenderMode {
212
    /// Use the default render mode, depending on whether the output is a TTY,
213
    /// and if a structured response is requested.
214
    #[default]
215
    Auto,
216

217
    /// Render the response as a stream of tokens.
218
    Streamed,
219

220
    /// Render the response as a buffered string.
221
    Buffered,
222
}
223

224
impl Query {
225
    pub(crate) async fn run(self, ctx: &mut Ctx) -> Output {
×
226
        debug!("Running `query` command.");
×
227
        trace!(args = ?self, "Received arguments.");
×
228

229
        let previous_id = self.update_active_conversation(ctx)?;
×
230
        let conversation_id = ctx.workspace.active_conversation_id();
×
231

232
        ctx.configure_active_mcp_servers().await?;
×
233
        let (user_query, query_file) = self.build_message(ctx, &conversation_id).await?;
×
234
        let history = ctx.workspace.get_messages(&conversation_id).to_messages();
×
235

236
        if let UserMessage::Query(query) = &user_query {
×
237
            if query.is_empty() {
×
238
                return cleanup(ctx, previous_id, query_file.as_deref()).map_err(Into::into);
×
239
            }
×
240

241
            // Generate title for new or empty conversations.
242
            if ctx.term.args.persist
×
243
                && (self.new_conversation || history.is_empty())
×
244
                && ctx.config().conversation.title.generate.auto
×
245
            {
246
                debug!("Generating title for new conversation");
×
247
                ctx.task_handler.spawn(TitleGeneratorTask::new(
×
248
                    conversation_id,
×
249
                    history.clone(),
×
250
                    ctx.config(),
×
251
                    Some(query.clone()),
×
252
                )?);
×
253
            }
×
254
        }
×
255

256
        let tools =
×
257
            tool_definitions(ctx.config().conversation.tools.iter(), &ctx.mcp_client).await?;
×
258

259
        let mut attachments = vec![];
×
260
        for attachment in &ctx.config().conversation.attachments {
×
261
            register_attachment(ctx, &attachment.to_url()?, &mut attachments).await?;
×
262
        }
263

264
        let thread = build_thread(
×
265
            user_query.clone(),
×
266
            history,
×
267
            attachments,
×
268
            &ctx.config().assistant,
×
269
            &tools,
×
270
        )?;
×
271

272
        let mut new_messages = vec![];
×
273
        if let Some(schema) = self.schema.clone() {
×
274
            new_messages.push(handle_structured_output(ctx, thread, schema).await?);
×
275
        } else {
276
            self.handle_stream(
×
277
                ctx,
×
278
                thread,
×
279
                ctx.config().assistant.tool_choice.clone(),
×
280
                tools,
×
281
                &mut new_messages,
×
282
                0,
×
283
            )
×
284
            .await?;
×
285
        }
286

287
        let reply = self.store_messages(ctx, conversation_id, new_messages)?;
×
288

289
        // Clean up the query file.
290
        if let Some(path) = query_file {
×
291
            fs::remove_file(path)?;
×
292
        }
×
293

294
        if self.schema.is_some() && !reply.is_empty() {
×
295
            if let RenderMode::Streamed = self.render_mode() {
×
296
                stdout::typewriter(&reply, ctx.config().style.typewriter.code_delay.into())?;
×
297
            } else {
298
                return Ok(Success::Json(serde_json::from_str(&reply)?));
×
299
            }
300
        }
×
301

302
        Ok(Success::Ok)
×
303
    }
×
304

305
    async fn build_message(
×
306
        &self,
×
307
        ctx: &mut Ctx,
×
308
        conversation_id: &ConversationId,
×
309
    ) -> Result<(UserMessage, Option<PathBuf>)> {
×
310
        // If replaying, remove the last message from the conversation, and use
311
        // its query message to build the new query.
312
        let mut message = self
×
313
            .replay
×
314
            .then(|| ctx.workspace.pop_message(conversation_id))
×
315
            .flatten()
×
316
            .map_or(UserMessage::Query(String::new()), |m| m.message);
×
317

318
        // If replaying a tool call, re-run the requested tool(s) and return the
319
        // new results.
320
        if let UserMessage::ToolCallResults(_) = &mut message {
×
321
            let messages = ctx.workspace.get_messages(conversation_id);
×
322
            let Some(response) = messages.last() else {
×
323
                return Err(Error::Replay("No assistant response found".into()));
×
324
            };
325

326
            let results = handle_tool_calls(ctx, response.reply.tool_calls.clone()).await?;
×
327
            message = UserMessage::ToolCallResults(results);
×
328
        }
×
329

330
        // If a query is provided, prepend it to the existing message. This is
331
        // only relevant for replays, otherwise the existing message is empty,
332
        // and we replace it with the provided query.
333
        if let Some(text) = &self.query {
×
334
            let text = text.join(" ");
×
335
            match &mut message {
×
336
                UserMessage::Query(query) if query.is_empty() => text.clone_into(query),
×
337
                UserMessage::Query(query) => *query = format!("{text}\n\n{query}"),
×
338
                UserMessage::ToolCallResults(_) => {}
×
339
            }
340
        }
×
341

342
        let query_file_path = self.edit_message(&mut message, ctx, conversation_id)?;
×
343

344
        if let UserMessage::Query(query) = &mut message
×
345
            && self.template
×
346
        {
347
            let mut env = Environment::empty();
×
348
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
349
            env.add_template("query", query)?;
×
350

351
            let tmpl = env.get_template("query")?;
×
352
            // TODO: supported nested variables
353
            for var in tmpl.undeclared_variables(false) {
×
354
                if ctx.config().template.values.contains_key(&var) {
×
355
                    continue;
×
356
                }
×
357

358
                return Err(Error::TemplateUndefinedVariable(var));
×
359
            }
360

361
            *query = tmpl.render(&ctx.config().template.values)?;
×
362
        }
×
363

364
        Ok((message, query_file_path))
×
365
    }
×
366

367
    fn update_active_conversation(&self, ctx: &mut Ctx) -> Result<ConversationId> {
×
368
        // Store the (old) active conversation ID, so that we can restore to it,
369
        // if the current conversation is aborted early (e.g. because of an
370
        // empty query or any other error).
371
        let last_active_conversation_id = ctx.workspace.active_conversation_id();
×
372

373
        // Set new active conversation if requested.
374
        if self.new_conversation {
×
375
            let id = ctx
×
376
                .workspace
×
377
                .create_conversation(Conversation::default().with_local(self.local));
×
378

379
            debug!(
×
380
                %id,
381
                local = %self.local,
382
                "Creating new active conversation due to --new flag."
×
383
            );
384

385
            ctx.workspace.set_active_conversation_id(id)?;
×
386
        }
×
387

388
        Ok(last_active_conversation_id)
×
389
    }
×
390

391
    // Open the editor for the query, if requested.
392
    fn edit_message(
×
393
        &self,
×
394
        message: &mut UserMessage,
×
395
        ctx: &mut Ctx,
×
396
        conversation_id: &ConversationId,
×
397
    ) -> Result<Option<PathBuf>> {
×
398
        // Editing only applies to queries, not tool-call results.
399
        let UserMessage::Query(query) = message else {
×
400
            return Ok(None);
×
401
        };
402

403
        // If there is no query provided, but the user explicitly requested not
404
        // to edit the query, we populate the query with a default message,
405
        // since most LLM providers do not support empty queries.
406
        if query.is_empty() && self.force_no_edit() {
×
407
            "<no content>".clone_into(query);
×
408
        }
×
409

410
        // If a query is provided, and editing is not explicitly requested, we
411
        // omit opening the editor.
412
        if !query.is_empty() && !self.force_edit() {
×
413
            return Ok(None);
×
414
        }
×
415

416
        let cmd = ctx.config().editor.command();
×
417
        let editor = match cmd {
×
418
            None if !query.is_empty() => return Ok(None),
×
419
            None => return Err(Error::MissingEditor),
×
420
            Some(cmd) => cmd,
×
421
        };
422

423
        let initial_message = if query.is_empty() {
×
424
            None
×
425
        } else {
426
            Some(query.to_owned())
×
427
        };
428

429
        // If replaying, pass the last query as the text to be edited,
430
        // otherwise open an empty editor.
431
        let query_file_path;
432
        (*query, query_file_path) =
×
433
            editor::edit_query(ctx, conversation_id, initial_message, editor)
×
434
                .map(|(q, p)| (q, Some(p)))?;
×
435

436
        Ok(query_file_path)
×
437
    }
×
438

439
    #[expect(clippy::too_many_lines)]
440
    async fn handle_stream(
×
441
        &self,
×
442
        ctx: &mut Ctx,
×
443
        mut thread: Thread,
×
444
        tool_choice: ToolChoice,
×
445
        tools: Vec<ToolDefinition>,
×
446
        messages: &mut Vec<MessagePair>,
×
447
        mut tries: usize,
×
448
    ) -> Result<()> {
×
449
        tries += 1;
×
450

NEW
451
        let model_id = &ctx
×
NEW
452
            .config()
×
NEW
453
            .assistant
×
NEW
454
            .model
×
NEW
455
            .id
×
NEW
456
            .finalize(&ctx.config().providers.llm.aliases)?;
×
457

458
        let parameters = &ctx.config().assistant.model.parameters;
×
459
        let provider = provider::get_provider(model_id.provider, &ctx.config().providers.llm)?;
×
460
        let message = thread.message.clone();
×
461
        let query = ChatQuery {
×
462
            thread: thread.clone(),
×
463

464
            // Limit the tools to the ones that are relevant to the tool choice.
465
            tools: match &tool_choice {
×
466
                ToolChoice::None => vec![],
×
467
                ToolChoice::Auto | ToolChoice::Required => tools.clone(),
×
468
                ToolChoice::Function(name) => tools
×
469
                    .clone()
×
470
                    .into_iter()
×
471
                    .filter(|v| &v.name == name)
×
472
                    .collect(),
×
473
            },
474
            tool_choice: tool_choice.clone(),
×
475
            ..Default::default()
×
476
        };
477
        let mut stream = provider
×
478
            .chat_completion_stream(model_id, parameters, query)
×
479
            .await?;
×
480

481
        let mut event_handler = StreamEventHandler {
×
482
            content_tokens: String::new(),
×
483
            reasoning_tokens: String::new(),
×
484
            tool_calls: vec![],
×
485
            tool_call_results: vec![],
×
486
        };
×
487

488
        let mut printer =
×
489
            ResponseHandler::new(self.render_mode(), ctx.config().style.tool_call.show);
×
490
        let mut metadata = BTreeMap::new();
×
491

492
        while let Some(event) = stream.next().await {
×
493
            let event = match event {
×
494
                Err(jp_llm::Error::RateLimit { retry_after }) => {
×
495
                    let max_tries = 5;
×
496
                    if tries > max_tries {
×
497
                        error!(tries, "Failed to get a non-rate-limited response.");
×
498
                        return Err(Error::Llm(jp_llm::Error::RateLimit { retry_after: None }));
×
499
                    }
×
500

501
                    let retry_after = retry_after.unwrap_or(Duration::from_secs(2));
×
502
                    warn!(
×
503
                        retry_after_secs = retry_after.as_secs(),
×
504
                        tries, max_tries, "Rate limited, retrying..."
×
505
                    );
506
                    tokio::time::sleep(retry_after).await;
×
507
                    return Box::pin(self.handle_stream(
×
508
                        ctx,
×
509
                        thread,
×
510
                        tool_choice,
×
511
                        tools,
×
512
                        messages,
×
513
                        tries,
×
514
                    ))
×
515
                    .await;
×
516
                }
517
                Err(jp_llm::Error::UnknownModel(model)) => {
×
518
                    let available = provider
×
519
                        .models()
×
520
                        .await?
×
521
                        .into_iter()
×
522
                        .map(|v| v.slug)
×
523
                        .collect();
×
524

525
                    return Err(Error::UnknownModel { model, available });
×
526
                }
527
                Err(e) => {
×
528
                    return Err(e.into());
×
529
                }
530
                Ok(event) => event,
×
531
            };
532

533
            let data = match event {
×
534
                StreamEvent::ChatChunk(chunk) => event_handler.handle_chat_chunk(ctx, chunk),
×
535
                StreamEvent::ToolCall(call) => {
×
536
                    event_handler
×
537
                        .handle_tool_call(ctx, call, &mut printer)
×
538
                        .await?
×
539
                }
540
                StreamEvent::Metadata(key, data) => {
×
541
                    metadata.insert(key, data);
×
542
                    continue;
×
543
                }
544
            };
545

546
            let Some(data) = data else {
×
547
                continue;
×
548
            };
549

550
            printer.handle(&data, ctx, false)?;
×
551
        }
552

553
        // Ensure we handle the last line of the stream.
554
        if !printer.buffer.is_empty() {
×
555
            printer.handle("\n", ctx, false)?;
×
556
        }
×
557

558
        let content_tokens = event_handler.content_tokens.trim().to_string();
×
559
        let content = if !content_tokens.is_empty() {
×
560
            Some(content_tokens)
×
561
        } else if content_tokens.is_empty() && event_handler.tool_calls.is_empty() {
×
562
            let max_tries = 3;
×
563
            if tries <= max_tries {
×
564
                warn!(tries, max_tries, "Empty response received, retrying...");
×
565

566
                return Box::pin(self.handle_stream(
×
567
                    ctx,
×
568
                    thread,
×
569
                    tool_choice,
×
570
                    tools,
×
571
                    messages,
×
572
                    tries,
×
573
                ))
×
574
                .await;
×
575
            }
×
576

577
            error!(tries, "Failed to get a non-empty response.");
×
578
            Some("<no reply>".to_string())
×
579
        } else {
580
            None
×
581
        };
582

583
        let reasoning_tokens = event_handler.reasoning_tokens.trim().to_string();
×
584
        let reasoning = if reasoning_tokens.is_empty() {
×
585
            None
×
586
        } else {
587
            Some(reasoning_tokens)
×
588
        };
589

590
        if let RenderMode::Buffered = printer.render_mode {
×
591
            println!("{}", printer.parsed.join("\n"));
×
592
        } else if content.is_some() || reasoning.is_some() {
×
593
            // Final newline.
×
594
            println!();
×
595
        }
×
596

597
        let message = MessagePair::new(message, AssistantMessage {
×
598
            provider: model_id.provider,
×
599
            metadata,
×
600
            content,
×
601
            reasoning,
×
602
            tool_calls: event_handler.tool_calls.clone(),
×
603
        });
×
604
        messages.push(message.clone());
×
605

606
        // If the assistant asked for a tool call, we handle it within the same
607
        // "conversation turn", essentially going into a "loop" until no more
608
        // tool calls are requested.
609
        if !event_handler.tool_call_results.is_empty() {
×
610
            thread.history.push(message, None);
×
611
            thread.message = UserMessage::ToolCallResults(event_handler.tool_call_results);
×
612

613
            Box::pin(self.handle_stream(
×
614
                ctx,
×
615
                thread,
×
616
                // After the first tool call, we revert back to letting the LLM
×
617
                // decide if/which tool to use.
×
618
                ToolChoice::Auto,
×
619
                tools,
×
620
                messages,
×
621
                0,
×
622
            ))
×
623
            .await?;
×
624
        }
×
625

626
        Ok(())
×
627
    }
×
628

629
    fn render_mode(&self) -> RenderMode {
×
630
        if self.no_stream {
×
631
            return RenderMode::Buffered;
×
632
        } else if self.stream {
×
633
            return RenderMode::Streamed;
×
634
        }
×
635

636
        RenderMode::Auto
×
637
    }
×
638

639
    /// Returns `true` if editing is explicitly disabled.
640
    ///
641
    /// This signals that even if no query is provided, no editor should be
642
    /// opened, but instead an empty query should be used.
643
    ///
644
    /// This can be used for example when requesting a tool call without needing
645
    /// additional context to be provided.
646
    fn force_no_edit(&self) -> bool {
×
647
        self.no_edit || matches!(self.edit, Some(Some(Editor::Disabled)))
×
648
    }
×
649

650
    /// Returns `true` if editing is explicitly enabled.
651
    ///
652
    /// This means the `--edit` flag was provided (but not `--edit=false`),
653
    /// which means the editor should be opened, regardless of whether a query
654
    /// is provided as an argument.
655
    fn force_edit(&self) -> bool {
×
656
        !self.force_no_edit() && self.edit.is_some()
×
657
    }
×
658

659
    fn store_messages(
×
660
        &self,
×
661
        ctx: &mut Ctx,
×
662
        conversation_id: ConversationId,
×
663
        new_messages: Vec<MessagePair>,
×
664
    ) -> Result<String> {
×
665
        let mut reply = String::new();
×
666

667
        for message in new_messages {
×
668
            debug!(
×
669
                conversation = %conversation_id,
670
                content_size_bytes = message.reply.content.as_deref().unwrap_or_default().len(),
×
671
                reasoning_size_bytes = message.reply.reasoning.as_deref().unwrap_or_default().len(),
×
672
                tool_calls_count = message.reply.tool_calls.len(),
×
673
                "Storing response message in conversation."
×
674
            );
675

676
            if let Some(content) = &message.reply.content {
×
677
                reply.push_str(content);
×
678
            }
×
679
            ctx.workspace.add_message(
×
680
                conversation_id,
×
681
                message,
×
682
                if self.new_conversation {
×
683
                    Some(ctx.config().to_partial())
×
684
                } else {
685
                    let global = ctx.term.args.config.clone();
×
686
                    let partial = load_cli_cfg_args(
×
687
                        PartialAppConfig::empty(),
×
688
                        &global,
×
689
                        Some(&ctx.workspace),
×
690
                    )?;
×
691

692
                    let partial_config = ctx.config().to_partial();
×
693
                    let partial = IntoPartialAppConfig::apply_cli_config(
×
694
                        self,
×
695
                        None,
×
696
                        partial,
×
697
                        Some(&partial_config),
×
698
                    )
699
                    .map_err(|error| Error::CliConfig(error.to_string()))?;
×
700

701
                    Some(partial)
×
702
                },
703
            );
704
        }
705

706
        Ok(reply)
×
707
    }
×
708
}
709

710
impl IntoPartialAppConfig for Query {
711
    fn apply_cli_config(
6✔
712
        &self,
6✔
713
        _workspace: Option<&Workspace>,
6✔
714
        mut partial: PartialAppConfig,
6✔
715
        merged_config: Option<&PartialAppConfig>,
6✔
716
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
6✔
717
        let Self {
718
            model,
6✔
719
            template: _,
720
            schema: _,
721
            replay: _,
722
            new_conversation: _,
723
            local: _,
724
            attachments,
6✔
725
            edit,
6✔
726
            no_edit,
6✔
727
            tool_use,
6✔
728
            no_tool_use,
6✔
729
            query: _,
730
            parameters,
6✔
731
            hide_reasoning,
6✔
732
            hide_tool_calls,
6✔
733
            stream: _,
734
            no_stream: _,
735
            tools: raw_tools,
6✔
736
            no_tools: raw_no_tools,
6✔
737
            reasoning,
6✔
738
            no_reasoning,
6✔
739
        } = &self;
6✔
740

741
        apply_model(&mut partial, model.as_deref(), merged_config);
6✔
742
        apply_editor(&mut partial, edit.as_ref().map(|v| v.as_ref()), *no_edit);
6✔
743
        apply_enable_tools(&mut partial, raw_tools, raw_no_tools, merged_config)?;
6✔
744
        apply_tool_use(
6✔
745
            &mut partial,
6✔
746
            tool_use.as_ref().map(|v| v.as_deref()),
6✔
747
            *no_tool_use,
6✔
748
        )?;
×
749
        apply_attachments(&mut partial, attachments);
6✔
750
        apply_reasoning(&mut partial, reasoning.as_ref(), *no_reasoning);
6✔
751

752
        for kv in parameters.clone() {
6✔
753
            partial.assistant.model.parameters.assign(kv)?;
×
754
        }
755

756
        if *hide_reasoning {
6✔
757
            partial.style.reasoning.show = Some(false);
×
758
        }
6✔
759

760
        if *hide_tool_calls {
6✔
761
            partial.style.tool_call.show = Some(false);
×
762
        }
6✔
763

764
        Ok(partial)
6✔
765
    }
6✔
766

767
    fn apply_conversation_config(
×
768
        &self,
×
769
        workspace: Option<&Workspace>,
×
770
        partial: PartialAppConfig,
×
771
        _: Option<&PartialAppConfig>,
×
772
    ) -> std::result::Result<PartialAppConfig, Box<dyn std::error::Error + Send + Sync>> {
×
773
        // New conversations do not apply any existing conversation
774
        // configurations. This is handled by the other configuration layers
775
        // (files, environment variables, CLI arguments).
776
        if self.new_conversation {
×
777
            return Ok(partial);
×
778
        }
×
779

780
        // If we're not inside a workspace, there is no active conversation to
781
        // fetch the configuration from.
782
        let Some(workspace) = workspace else {
×
783
            return Ok(partial);
×
784
        };
785

786
        let id = workspace.active_conversation_id();
×
787

788
        load_partial(partial, workspace.get_messages(&id).config()).map_err(Into::into)
×
789
    }
×
790
}
791

792
fn build_thread(
×
793
    user_message: UserMessage,
×
794
    history: Messages,
×
795
    attachments: Vec<Attachment>,
×
796
    assistant: &AssistantConfig,
×
797
    tools: &[ToolDefinition],
×
798
) -> Result<Thread> {
×
799
    let mut thread_builder = ThreadBuilder::default()
×
800
        .with_system_prompt(assistant.system_prompt.clone())
×
801
        .with_instructions(assistant.instructions.clone())
×
802
        .with_attachments(attachments)
×
803
        .with_history(history)
×
804
        .with_message(user_message);
×
805

806
    if !tools.is_empty() {
×
807
        let instruction = InstructionsConfig::default()
×
808
            .with_title("Tool Usage")
×
809
            .with_description("How to leverage the tools available to you.".to_string())
×
810
            .with_item("Use all the tools available to you to give the best possible answer.")
×
811
            .with_item("Verify the tool name, description and parameters are correct.")
×
812
            .with_item(
×
813
                "Even if you've reasoned yourself towards a solution, use any available tool to \
×
814
                 verify your answer.",
×
815
            );
×
816

×
817
        thread_builder = thread_builder.with_instruction(instruction);
×
818
    }
×
819

820
    Ok(thread_builder.build()?)
×
821
}
×
822

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

NEW
827
    partial.assistant.model.id = id.into();
×
828
}
6✔
829

830
/// Apply the CLI editor configuration to the partial configuration.
831
fn apply_editor(partial: &mut PartialAppConfig, editor: Option<Option<&Editor>>, no_edit: bool) {
6✔
832
    let Some(Some(editor)) = editor else {
×
833
        return;
6✔
834
    };
835

836
    match (no_edit, editor) {
×
837
        (true, _) | (_, Editor::Disabled) => {
×
838
            partial.editor.cmd = None;
×
839
            partial.editor.envs = None;
×
840
        }
×
841
        (_, Editor::Default) => {}
×
842
        (_, Editor::Command(cmd)) => partial.editor.cmd = Some(cmd.clone()),
×
843
    }
844
}
6✔
845

846
fn apply_enable_tools(
6✔
847
    partial: &mut PartialAppConfig,
6✔
848
    raw_tools: &[Option<String>],
6✔
849
    raw_no_tools: &[Option<String>],
6✔
850
    merged_config: Option<&PartialAppConfig>,
6✔
851
) -> BoxedResult<()> {
6✔
852
    let tools = if raw_tools.is_empty() {
6✔
853
        None
3✔
854
    } else if raw_tools.iter().any(Option::is_none) {
3✔
855
        Some(vec![])
1✔
856
    } else {
857
        Some(raw_tools.iter().filter_map(|v| v.as_deref()).collect())
3✔
858
    };
859

860
    let no_tools = if raw_no_tools.is_empty() {
6✔
861
        None
4✔
862
    } else if raw_no_tools.iter().any(Option::is_none) {
2✔
863
        Some(vec![])
1✔
864
    } else {
865
        Some(raw_no_tools.iter().filter_map(|v| v.as_deref()).collect())
1✔
866
    };
867

868
    let enable_all = tools.as_ref().is_some_and(Vec::is_empty);
6✔
869
    let disable_all = no_tools.as_ref().is_some_and(Vec::is_empty);
6✔
870

871
    if enable_all && disable_all {
6✔
872
        return Err("cannot pass both --no-tools and --tools without arguments".into());
×
873
    }
6✔
874

875
    let existing_tools = merged_config.map_or(&partial.conversation.tools.tools, |v| {
6✔
876
        &v.conversation.tools.tools
×
877
    });
×
878

879
    let missing = tools
6✔
880
        .iter()
6✔
881
        .flatten()
6✔
882
        .chain(no_tools.iter().flatten())
6✔
883
        .filter(|name| !existing_tools.contains_key(**name))
6✔
884
        .collect::<HashSet<_>>();
6✔
885

886
    if missing.len() == 1 {
6✔
887
        return Err(ToolError::NotFound {
×
888
            name: missing.iter().next().unwrap().to_string(),
×
889
        }
×
890
        .into());
×
891
    } else if !missing.is_empty() {
6✔
892
        return Err(ToolError::NotFoundN {
×
893
            names: missing.into_iter().map(ToString::to_string).collect(),
×
894
        }
×
895
        .into());
×
896
    }
6✔
897

898
    // Disable tools.
899
    if let Some(no_tools) = no_tools {
6✔
900
        partial
2✔
901
            .conversation
2✔
902
            .tools
2✔
903
            .tools
2✔
904
            .iter_mut()
2✔
905
            .filter(|(name, _)| disable_all || no_tools.iter().any(|v| v == name))
6✔
906
            .for_each(|(_, v)| v.enable = Some(false));
4✔
907
    }
4✔
908

909
    // Enable tools.
910
    if let Some(tools) = tools {
6✔
911
        partial
3✔
912
            .conversation
3✔
913
            .tools
3✔
914
            .tools
3✔
915
            .iter_mut()
3✔
916
            .filter(|(name, _)| enable_all || tools.iter().any(|v| v == *name))
9✔
917
            .for_each(|(_, v)| v.enable = Some(true));
6✔
918
    }
3✔
919

920
    Ok(())
6✔
921
}
6✔
922

923
/// Apply the CLI tool use configuration to the partial configuration.
924
///
925
/// NOTE: This has to run *after* `apply_enable_tools` because it will return an
926
/// error if the tool of choice is not enabled.
927
fn apply_tool_use(
6✔
928
    partial: &mut PartialAppConfig,
6✔
929
    tool_choice: Option<Option<&str>>,
6✔
930
    no_tool_choice: bool,
6✔
931
) -> BoxedResult<()> {
6✔
932
    if no_tool_choice || matches!(tool_choice, Some(Some("false"))) {
6✔
933
        partial.assistant.tool_choice = Some(ToolChoice::None);
×
934
        return Ok(());
×
935
    }
6✔
936

937
    let Some(tool) = tool_choice else {
6✔
938
        return Ok(());
6✔
939
    };
940

941
    partial.assistant.tool_choice = match tool {
×
942
        None | Some("true") => Some(ToolChoice::Required),
×
943
        Some(v) => {
×
944
            if !partial
×
945
                .conversation
×
946
                .tools
×
947
                .tools
×
948
                .iter()
×
949
                .filter(|(_, cfg)| cfg.enable.is_some_and(|v| v))
×
950
                .any(|(name, _)| name == v)
×
951
            {
952
                return Err(format!("tool choice '{v}' does not match any enabled tools").into());
×
953
            }
×
954

955
            Some(ToolChoice::Function(v.to_owned()))
×
956
        }
957
    };
958

959
    Ok(())
×
960
}
6✔
961

962
/// Apply the CLI attachments to the partial configuration.
963
fn apply_attachments(partial: &mut PartialAppConfig, attachments: &[Url]) {
6✔
964
    if attachments.is_empty() {
6✔
965
        return;
6✔
966
    }
×
967

968
    partial
×
969
        .conversation
×
970
        .attachments
×
971
        .extend(attachments.iter().cloned().map(Into::into));
×
972
}
6✔
973

974
/// Apply the CLI reasoning configuration to the partial configuration.
975
fn apply_reasoning(
6✔
976
    partial: &mut PartialAppConfig,
6✔
977
    reasoning: Option<&ReasoningConfig>,
6✔
978
    no_reasoning: bool,
6✔
979
) {
6✔
980
    if no_reasoning {
6✔
981
        partial.assistant.model.parameters.reasoning = Some(PartialReasoningConfig::Off);
×
982
        return;
×
983
    }
6✔
984

985
    let Some(reasoning) = reasoning else {
6✔
986
        return;
6✔
987
    };
988

989
    partial.assistant.model.parameters.reasoning = Some(match reasoning {
×
990
        ReasoningConfig::Off => PartialReasoningConfig::Off,
×
991
        ReasoningConfig::Auto => PartialReasoningConfig::Auto,
×
992
        ReasoningConfig::Custom(custom) => PartialCustomReasoningConfig {
×
993
            effort: Some(custom.effort),
×
994
            exclude: Some(custom.exclude),
×
995
        }
×
996
        .into(),
×
997
    });
998
}
6✔
999

1000
/// Clean up empty queries.
1001
fn cleanup(
×
1002
    ctx: &mut Ctx,
×
1003
    last_active_conversation_id: ConversationId,
×
1004
    query_file_path: Option<&Path>,
×
1005
) -> Result<Success> {
×
1006
    let conversation_id = ctx.workspace.active_conversation_id();
×
1007

1008
    info!("Query is empty, exiting.");
×
1009
    if last_active_conversation_id != conversation_id {
×
1010
        ctx.workspace
×
1011
            .set_active_conversation_id(last_active_conversation_id)?;
×
1012
        ctx.workspace.remove_conversation(&conversation_id)?;
×
1013
    }
×
1014

1015
    if let Some(path) = query_file_path {
×
1016
        fs::remove_file(path)?;
×
1017
    }
×
1018

1019
    Ok("Query is empty, ignoring.".into())
×
1020
}
×
1021

1022
async fn handle_structured_output(
×
1023
    ctx: &mut Ctx,
×
1024
    thread: Thread,
×
1025
    schema: schemars::Schema,
×
1026
) -> Result<MessagePair> {
×
NEW
1027
    let model_id = &ctx
×
NEW
1028
        .config()
×
NEW
1029
        .assistant
×
NEW
1030
        .model
×
NEW
1031
        .id
×
NEW
1032
        .finalize(&ctx.config().providers.llm.aliases)?;
×
1033

1034
    let parameters = &ctx.config().assistant.model.parameters;
×
1035
    let provider = provider::get_provider(model_id.provider, &ctx.config().providers.llm)?;
×
1036
    let message = thread.message.clone();
×
1037
    let query = StructuredQuery::new(schema, thread);
×
1038

1039
    let value = provider
×
1040
        .structured_completion(model_id, parameters, query)
×
1041
        .await?;
×
1042
    let content = if ctx.term.is_tty {
×
1043
        serde_json::to_string_pretty(&value)?
×
1044
    } else {
1045
        serde_json::to_string(&value)?
×
1046
    };
1047

1048
    Ok(MessagePair::new(
×
1049
        message,
×
1050
        AssistantMessage::from((model_id.provider, content)),
×
1051
    ))
×
1052
}
×
1053

1054
#[expect(clippy::needless_pass_by_value)]
1055
fn json_schema(s: String) -> Result<schemars::Schema> {
×
1056
    serde_json::from_str::<serde_json::Value>(&s)?
×
1057
        .try_into()
×
1058
        .map_err(Into::into)
×
1059
}
×
1060

1061
fn string_or_path(s: &str) -> Result<String> {
×
1062
    if let Some(s) = s
×
1063
        .strip_prefix(PATH_STRING_PREFIX)
×
1064
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
1065
    {
1066
        return fs::read_to_string(s).map_err(Into::into);
×
1067
    }
×
1068

1069
    Ok(s.to_owned())
×
1070
}
×
1071

1072
struct Line {
1073
    content: String,
1074
    variant: LineVariant,
1075
}
1076

1077
#[derive(Debug)]
1078
enum LineVariant {
1079
    Normal,
1080
    Code,
1081
    Raw,
1082
    FencedCodeBlockStart { language: Option<String> },
1083
    FencedCodeBlockEnd { indent: usize },
1084
}
1085

1086
impl Line {
1087
    fn new(content: String, in_fenced_code_block: bool, raw: bool) -> Self {
×
1088
        let variant = if raw {
×
1089
            LineVariant::Raw
×
1090
        } else if in_fenced_code_block && content.trim().ends_with("```") {
×
1091
            let indent = content.chars().take_while(|c| c.is_whitespace()).count();
×
1092

1093
            LineVariant::FencedCodeBlockEnd { indent }
×
1094
        } else if content.trim_start().starts_with("```") {
×
1095
            let language = content
×
1096
                .trim_start()
×
1097
                .chars()
×
1098
                .skip(3)
×
1099
                .take_while(|c| c.is_alphanumeric())
×
1100
                .collect::<String>();
×
1101
            let language = if language.is_empty() {
×
1102
                None
×
1103
            } else {
1104
                Some(language)
×
1105
            };
1106

1107
            LineVariant::FencedCodeBlockStart { language }
×
1108
        } else if in_fenced_code_block {
×
1109
            LineVariant::Code
×
1110
        } else {
1111
            LineVariant::Normal
×
1112
        };
1113

1114
        Line { content, variant }
×
1115
    }
×
1116
}
1117

1118
#[cfg(test)]
1119
mod tests {
1120
    use indexmap::IndexMap;
1121
    use jp_config::conversation::tool::PartialToolConfig;
1122

1123
    use super::*;
1124

1125
    #[test]
1126
    #[expect(clippy::too_many_lines)]
1127
    fn test_query_tools_and_no_tools() {
1✔
1128
        // Create a partial configuration with a few tools.
1129
        let mut partial = PartialAppConfig::default();
1✔
1130
        partial.conversation.tools.tools = IndexMap::from_iter([
1✔
1131
            ("implicitly_enabled_tool".into(), PartialToolConfig {
1✔
1132
                enable: None,
1✔
1133
                ..Default::default()
1✔
1134
            }),
1✔
1135
            ("explicitly_enabled_tool".into(), PartialToolConfig {
1✔
1136
                enable: Some(true),
1✔
1137
                ..Default::default()
1✔
1138
            }),
1✔
1139
            ("explicitly_disabled_tool".into(), PartialToolConfig {
1✔
1140
                enable: Some(false),
1✔
1141
                ..Default::default()
1✔
1142
            }),
1✔
1143
        ]);
1✔
1144

1145
        // Keep all tools as-is.
1146
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1147
            &Query {
1✔
1148
                no_tools: vec![],
1✔
1149
                ..Default::default()
1✔
1150
            },
1✔
1151
            None,
1✔
1152
            partial,
1✔
1153
            None,
1✔
1154
        )
1155
        .unwrap();
1✔
1156

1157
        assert_eq!(
1✔
1158
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1159
            None,
1160
        );
1161
        assert_eq!(
1✔
1162
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1163
            Some(true)
1164
        );
1165
        assert_eq!(
1✔
1166
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1167
            Some(false)
1168
        );
1169

1170
        // Disable one tool.
1171
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1172
            &Query {
1✔
1173
                no_tools: vec![Some("implicitly_enabled_tool".into())],
1✔
1174
                ..Default::default()
1✔
1175
            },
1✔
1176
            None,
1✔
1177
            partial,
1✔
1178
            None,
1✔
1179
        )
1180
        .unwrap();
1✔
1181

1182
        assert_eq!(
1✔
1183
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1184
            Some(false),
1185
        );
1186
        assert_eq!(
1✔
1187
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1188
            Some(true)
1189
        );
1190
        assert_eq!(
1✔
1191
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1192
            Some(false)
1193
        );
1194

1195
        // Enable one tool.
1196
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1197
            &Query {
1✔
1198
                tools: vec![Some("explicitly_disabled_tool".into())],
1✔
1199
                ..Default::default()
1✔
1200
            },
1✔
1201
            None,
1✔
1202
            partial,
1✔
1203
            None,
1✔
1204
        )
1205
        .unwrap();
1✔
1206

1207
        assert_eq!(
1✔
1208
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1209
            Some(false),
1210
        );
1211
        assert_eq!(
1✔
1212
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1213
            Some(true)
1214
        );
1215
        assert_eq!(
1✔
1216
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1217
            Some(true)
1218
        );
1219

1220
        // Enable all tools.
1221
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1222
            &Query {
1✔
1223
                tools: vec![None],
1✔
1224
                ..Default::default()
1✔
1225
            },
1✔
1226
            None,
1✔
1227
            partial,
1✔
1228
            None,
1✔
1229
        )
1230
        .unwrap();
1✔
1231

1232
        assert_eq!(
1✔
1233
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1234
            Some(true),
1235
        );
1236
        assert_eq!(
1✔
1237
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1238
            Some(true)
1239
        );
1240
        assert_eq!(
1✔
1241
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1242
            Some(true)
1243
        );
1244

1245
        // Disable all tools.
1246
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1247
            &Query {
1✔
1248
                no_tools: vec![None],
1✔
1249
                ..Default::default()
1✔
1250
            },
1✔
1251
            None,
1✔
1252
            partial,
1✔
1253
            None,
1✔
1254
        )
1255
        .unwrap();
1✔
1256

1257
        assert_eq!(
1✔
1258
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1259
            Some(false),
1260
        );
1261
        assert_eq!(
1✔
1262
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1263
            Some(false)
1264
        );
1265
        assert_eq!(
1✔
1266
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1267
            Some(false)
1268
        );
1269

1270
        // Enable multiple tools.
1271
        partial = IntoPartialAppConfig::apply_cli_config(
1✔
1272
            &Query {
1✔
1273
                tools: vec![
1✔
1274
                    Some("explicitly_disabled_tool".into()),
1✔
1275
                    Some("explicitly_enabled_tool".into()),
1✔
1276
                ],
1✔
1277
                ..Default::default()
1✔
1278
            },
1✔
1279
            None,
1✔
1280
            partial,
1✔
1281
            None,
1✔
1282
        )
1283
        .unwrap();
1✔
1284

1285
        assert_eq!(
1✔
1286
            partial.conversation.tools.tools["implicitly_enabled_tool"].enable,
1✔
1287
            Some(false),
1288
        );
1289
        assert_eq!(
1✔
1290
            partial.conversation.tools.tools["explicitly_enabled_tool"].enable,
1✔
1291
            Some(true)
1292
        );
1293
        assert_eq!(
1✔
1294
            partial.conversation.tools.tools["explicitly_disabled_tool"].enable,
1✔
1295
            Some(true)
1296
        );
1297
    }
1✔
1298
}
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