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

dcdpr / jp / 15614166822

12 Jun 2025 03:03PM UTC coverage: 38.737% (+0.01%) from 38.724%
15614166822

push

github

web-flow
refactor: change `Query::[no_]stream` from `bool` to `StreamMode` (#159)

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

0 of 4 new or added lines in 1 file covered. (0.0%)

3618 of 9340 relevant lines covered (38.74%)

4.66 hits per line

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

0.0
/crates/jp_cli/src/cmd/query.rs
1
use std::{
2
    collections::{BTreeMap, HashSet},
3
    convert::Infallible,
4
    env, fs,
5
    path::PathBuf,
6
    str::FromStr,
7
};
8

9
use clap::{
10
    builder::{BoolishValueParser, TypedValueParser as _},
11
    ArgAction,
12
};
13
use crossterm::style::{Color, Stylize as _};
14
use futures::StreamExt as _;
15
use jp_config::{expand_tilde, style::code::LinkStyle};
16
use jp_conversation::{
17
    message::{ToolCallRequest, ToolCallResult},
18
    persona::Instructions,
19
    thread::{Thread, ThreadBuilder},
20
    AssistantMessage, Context, ContextId, Conversation, ConversationId, MessagePair, Model,
21
    ModelId, Persona, PersonaId, UserMessage,
22
};
23
use jp_llm::provider::{self, CompletionChunk, StreamEvent};
24
use jp_mcp::{config::McpServerId, tool::ToolChoice, ResourceContents, Tool};
25
use jp_query::query::{ChatQuery, StructuredQuery};
26
use jp_task::task::TitleGeneratorTask;
27
use jp_term::{code, osc::hyperlink, stdout};
28
use minijinja::{Environment, UndefinedBehavior};
29
use termimad::FmtText;
30
use tracing::{debug, info, trace};
31
use url::Url;
32

33
use super::{attachment::register_attachment, Output};
34
use crate::{
35
    cmd::Success,
36
    editor::{self, Editor},
37
    error::{Error, Result},
38
    parser, Ctx, KeyValue, PATH_STRING_PREFIX,
39
};
40

41
#[derive(Debug, clap::Args)]
42
pub struct Args {
43
    /// The query to send. If not provided, uses `$JP_EDITOR`, `$VISUAL` or
44
    /// `$EDITOR` to open edit the query in an editor.
45
    #[arg(value_parser = string_or_path)]
46
    pub query: Option<String>,
47

48
    /// Use the query string as a Jinja2 template.
49
    ///
50
    /// You can provide values for template variables using the
51
    /// `template.values` config key.
52
    #[arg(long)]
53
    pub template: bool,
54

55
    #[arg(long, value_parser = string_or_path.try_map(json_schema))]
56
    pub schema: Option<schemars::Schema>,
57

58
    /// Replay the last message in the conversation.
59
    ///
60
    /// If a query is provided, it will be appended to the end of the previous
61
    /// message. If no query is provided, $EDITOR will open with the last
62
    /// message in the conversation.
63
    #[arg(long = "replay", conflicts_with = "new_conversation")]
64
    pub replay: bool,
65

66
    /// Start a new conversation without any message history.
67
    ///
68
    /// If a context named `default` exists, it will be attached to the
69
    /// conversation.
70
    #[arg(short = 'n', long = "new")]
71
    pub new_conversation: bool,
72

73
    /// Store the conversation locally, outside of the workspace.
74
    #[arg(short = 'l', long = "local", requires = "new_conversation")]
75
    pub local: bool,
76

77
    /// Add attachment to the context.
78
    #[arg(short = 'a', long = "attachment", value_parser = |s: &str| parser::attachment_url(s))]
×
79
    pub attachments: Vec<Url>,
80

81
    /// Use specific persona.
82
    #[arg(short = 'p', long = "persona", value_parser = PersonaId::from_str)]
83
    pub persona: Option<PersonaId>,
84

85
    /// Use specific context.
86
    #[arg(short = 'x', long = "context", value_parser = |s: &str| ContextId::try_from(s))]
×
87
    pub context: Option<ContextId>,
88

89
    /// Use specific MCP servers exclusively.
90
    #[arg(short = 'm', long = "mcp", value_parser = |s: &str| Ok::<_, Infallible>(McpServerId::new(s)))]
×
91
    pub mcp: Vec<McpServerId>,
92

93
    /// Whether and how to edit the query.
94
    #[arg(short = 'e', long = "edit", conflicts_with = "no_edit")]
95
    pub edit: Option<Option<Editor>>,
96

97
    /// Do not edit the query.
98
    #[arg(short = 'E', long = "no-edit", conflicts_with = "edit")]
99
    pub no_edit: bool,
100

101
    /// The model to use.
102
    #[arg(short = 'o', long = "model", value_parser = ModelId::from_str)]
103
    pub model: Option<ModelId>,
104

105
    /// The model parameters to use.
106
    #[arg(short = 'r', long = "param", value_name = "KEY=VALUE", action = ArgAction::Append, value_parser = KeyValue::from_str)]
107
    pub parameters: Vec<KeyValue>,
108

109
    /// Do not display the reasoning content.
110
    ///
111
    /// This does not stop the assistant from generating reasoning tokens to
112
    /// help with its accuracy, but it does not display them in the output.
113
    #[arg(long = "hide-reasoning")]
114
    pub hide_reasoning: bool,
115

116
    /// Stream the assistant's response as it is generated.
117
    ///
118
    /// This is the default behaviour for TTY sessions, but can be forced for
119
    /// non-TTY sessions by setting this flag.
120
    #[arg(short = 's', long = "stream", conflicts_with = "no_stream", value_parser = BoolishValueParser::new()
NEW
121
        .map(|b| if b { StreamMode::Forced(true) } else { StreamMode::Auto }))]
×
122
    pub stream: StreamMode,
123

124
    /// Disable streaming the assistant's response.
125
    ///
126
    /// This is the default behaviour for non-TTY sessions, or for structured
127
    /// responses, but can be forced by setting this flag.
128
    #[arg(short = 'S', long = "no-stream", conflicts_with = "stream", value_parser = BoolishValueParser::new()
NEW
129
        .map(|b| if b { StreamMode::Forced(false) } else { StreamMode::Auto }))]
×
130
    pub no_stream: StreamMode,
131

132
    /// The tool to use.
133
    ///
134
    /// If a value is provided, the tool matching the value will be used.
135
    ///
136
    /// Note that this setting is *not* persisted across queries. To persist
137
    /// tool choice behavior, use a named context with the `tool_choice` field,
138
    /// or set `llm.tool_choice` in the config file.
139
    #[arg(short = 't', long = "tool")]
140
    pub tool_choice: Option<Option<String>>,
141

142
    /// Disable tool use by the assistant.
143
    #[arg(short = 'T', long = "no-tool")]
144
    pub no_tool_choice: bool,
145
}
146

147
/// The stream mode to use for the assistant's response.
148
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
149
pub(crate) enum StreamMode {
150
    /// Use the default stream mode, depending on whether the output is a TTY,
151
    /// and if a structured response is requested.
152
    #[default]
153
    Auto,
154

155
    /// Disable contextual streaming mode, forcing the assistant's response to
156
    /// either be streamed or buffered.
157
    Forced(bool),
158
}
159

160
impl Args {
161
    #[expect(clippy::too_many_lines)]
162
    pub async fn run(self, ctx: &mut Ctx) -> Output {
×
163
        debug!("Running `query` command.");
×
164
        trace!(args = ?self, "Received arguments.");
×
165

166
        self.update_config(&mut ctx.config)?;
×
167

168
        let last_active_conversation_id = ctx.workspace.active_conversation_id();
×
169
        let conversation_id = self.get_conversation_id(ctx)?;
×
170

171
        self.update_context(ctx).await?;
×
172

173
        // Ensure we start the MCP servers attached to the conversation.
174
        ctx.configure_active_mcp_servers().await?;
×
175

176
        let model = self.get_model(ctx)?;
×
177
        let (message, query_file_path) = self.build_message(ctx, conversation_id, &model).await?;
×
178

179
        if let UserMessage::Query(query) = &message {
×
180
            // Clean up after empty queries.
181
            if query.is_empty() {
×
182
                info!("Query is empty, exiting.");
×
183

184
                if last_active_conversation_id != conversation_id {
×
185
                    ctx.workspace
×
186
                        .set_active_conversation_id(last_active_conversation_id)?;
×
187
                    ctx.workspace.remove_conversation(&conversation_id)?;
×
188
                }
×
189

190
                if let Some(path) = query_file_path {
×
191
                    fs::remove_file(path)?;
×
192
                }
×
193

194
                return Ok("Query is empty, ignoring.".into());
×
195
            }
×
196

197
            // Generate title for new conversations.
198
            if ctx.term.args.persist
×
199
                && self.new_conversation
×
200
                && ctx.config.conversation.title.generate.auto
×
201
            {
202
                debug!("Generating title for new conversation");
×
203
                ctx.task_handler.spawn(TitleGeneratorTask::new(
×
204
                    conversation_id,
×
205
                    &ctx.config,
×
206
                    &ctx.workspace,
×
207
                    Some(query.clone()),
×
208
                ));
209
            }
×
210
        }
×
211

212
        let conversation = ctx.workspace.get_active_conversation();
×
213
        let persona_id = &conversation.context.persona_id;
×
214
        let persona = ctx
×
215
            .workspace
×
216
            .get_persona(persona_id)
×
217
            .ok_or(Error::NotFound("Persona", persona_id.to_string()))?;
×
218

219
        let tool_choice = self.tool_choice(ctx);
×
220
        let tools = ctx.mcp_client.list_tools().await?;
×
221

222
        let mut attachments = vec![];
×
223
        for handler in conversation.context.attachment_handlers.values() {
×
224
            attachments.extend(
×
225
                handler
×
226
                    .get(&ctx.workspace.root, ctx.mcp_client.clone())
×
227
                    .await?,
×
228
            );
229
        }
230

231
        let thread = build_thread(
×
232
            ctx,
×
233
            conversation_id,
×
234
            attachments,
×
235
            persona,
×
236
            message.clone(),
×
237
            &tools,
×
238
        )?;
×
239

240
        let context = conversation.context.clone();
×
241
        let mut messages = vec![];
×
242
        if let Some(schema) = self.schema.clone() {
×
243
            messages.push(handle_structured_output(ctx, context, thread, &model, schema).await?);
×
244
        } else {
245
            handle_stream(
×
246
                ctx,
×
247
                context,
×
248
                thread,
×
249
                &model,
×
250
                tools.clone(),
×
251
                tool_choice,
×
252
                &mut messages,
×
NEW
253
                self.stream,
×
254
            )
×
255
            .await?;
×
256
        }
257

258
        let mut reply = String::new();
×
259
        for message in messages {
×
260
            trace!(
×
261
                conversation = %conversation_id,
262
                content_size = message.reply.content.as_deref().unwrap_or_default().len(),
×
263
                reasoning_size = message
×
264
                    .reply
×
265
                    .reasoning
×
266
                    .as_ref()
×
267
                    .map(ToString::to_string)
×
268
                    .unwrap_or_default()
×
269
                    .len(),
×
270
                "Storing response message in conversation."
×
271
            );
272

273
            if let Some(content) = &message.reply.content {
×
274
                reply.push_str(content);
×
275
            }
×
276
            ctx.workspace.add_message(conversation_id, message.clone());
×
277
        }
278

279
        // Clean up the query file.
280
        if let Some(path) = query_file_path {
×
281
            fs::remove_file(path)?;
×
282
        }
×
283

284
        if self.schema.is_some() && !reply.is_empty() {
×
NEW
285
            if let StreamMode::Forced(true) = self.stream {
×
286
                stdout::typewriter(&reply, ctx.config.style.typewriter.code_delay)?;
×
287
            } else {
288
                return Ok(Success::Json(serde_json::from_str(&reply)?));
×
289
            }
290
        }
×
291

292
        Ok(Success::Ok)
×
293
    }
×
294

295
    async fn build_message(
×
296
        &self,
×
297
        ctx: &mut Ctx,
×
298
        conversation_id: ConversationId,
×
299
        model: &Model,
×
300
    ) -> Result<(UserMessage, Option<PathBuf>)> {
×
301
        // If replaying, remove the last message from the conversation, and use
302
        // its query message to build the new query.
303
        let mut message = self
×
304
            .replay
×
305
            .then(|| ctx.workspace.pop_message(&conversation_id))
×
306
            .flatten()
×
307
            .map_or(UserMessage::Query(String::new()), |m| m.message);
×
308

309
        // If replaying a tool call, re-run the requested tool(s) and return the
310
        // new results.
311
        if let UserMessage::ToolCallResults(_) = &mut message {
×
312
            let Some(response) = ctx.workspace.get_messages(&conversation_id).last() else {
×
313
                return Err(Error::Replay("No assistant response found".into()));
×
314
            };
315

316
            let results = handle_tool_calls(ctx, response.reply.tool_calls.clone()).await?;
×
317
            message = UserMessage::ToolCallResults(results);
×
318
        }
×
319

320
        // If a query is provided, prepend it to the existing message. This is
321
        // only relevant for replays, otherwise the existing message is empty,
322
        // and we replace it with the provided query.
323
        if let Some(text) = &self.query {
×
324
            match &mut message {
×
325
                UserMessage::Query(query) if query.is_empty() => text.clone_into(query),
×
326
                UserMessage::Query(query) => *query = format!("{text}\n\n{query}"),
×
327
                UserMessage::ToolCallResults(_) => {}
×
328
            }
329
        }
×
330

331
        let query_file_path = self.edit_message(&mut message, ctx, conversation_id, model)?;
×
332

333
        if let UserMessage::Query(query) = &mut message
×
334
            && self.template
×
335
        {
336
            let mut env = Environment::empty();
×
337
            env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
×
338
            env.add_template("query", query)?;
×
339

340
            let tmpl = env.get_template("query")?;
×
341
            // TODO: supported nested variables
342
            for var in tmpl.undeclared_variables(false) {
×
343
                if ctx.config.template.values.contains_key(&var) {
×
344
                    continue;
×
345
                }
×
346

347
                return Err(Error::TemplateUndefinedVariable(var));
×
348
            }
349

350
            *query = tmpl.render(&ctx.config.template.values)?;
×
351
        }
×
352

353
        Ok((message, query_file_path))
×
354
    }
×
355

356
    /// Update the conversation context based on the contextual information
357
    /// passed in through the CLI, configuration, and environment variables.
358
    async fn update_context(&self, ctx: &mut Ctx) -> Result<()> {
×
359
        // Update context if specified
360
        if let Some(id) = ctx.config.conversation.context.clone() {
×
361
            debug!(
×
362
                %id,
363
                "Using named context in conversation due to conversation.context config."
×
364
            );
365

366
            // Get context.
367
            let context = ctx
×
368
                .workspace
×
369
                .get_named_context(&id)
×
370
                .ok_or(Error::NotFound("Context", id.to_string()))?
×
371
                .clone();
×
372

373
            // Update conversation context.
374
            ctx.workspace.get_active_conversation_mut().context = context;
×
375
        }
×
376

377
        // Update persona if specified
378
        if let Some(id) = ctx.config.conversation.persona.clone() {
×
379
            debug!(
×
380
                %id,
381
                "Changing persona in conversation context due to conversation.persona config."
×
382
            );
383

384
            // Ensure persona exists.
385
            ctx.workspace
×
386
                .get_persona(&id)
×
387
                .ok_or(Error::NotFound("Persona", id.to_string()))?;
×
388

389
            // Update context with new persona.
390
            ctx.workspace
×
391
                .get_active_conversation_mut()
×
392
                .context
×
393
                .persona_id = id;
×
394
        }
×
395

396
        // Add any new attachments specified in arguments
397
        for attachment in &self.attachments {
×
398
            let context = &mut ctx.workspace.get_active_conversation_mut().context;
×
399
            register_attachment(attachment, context).await?;
×
400
        }
401

402
        // Set exclusive MCP servers
403
        let mut servers = HashSet::new();
×
404
        for id in &self.mcp {
×
405
            // Ensure MCP server exists.
406
            ctx.workspace
×
407
                .get_mcp_server(id)
×
408
                .ok_or(Error::NotFound("MCP server", id.to_string()))?;
×
409

410
            servers.insert(id.clone());
×
411
        }
412

413
        if !servers.is_empty() {
×
414
            debug!(
×
415
                servers = servers
×
416
                    .iter()
×
417
                    .map(ToString::to_string)
×
418
                    .collect::<Vec<_>>()
×
419
                    .join(", "),
×
420
                "Overriding MCP server in conversation context due to --mcp flag."
×
421
            );
422

423
            ctx.workspace
×
424
                .get_active_conversation_mut()
×
425
                .context
×
426
                .mcp_server_ids = servers;
×
427
        }
×
428

429
        Ok(())
×
430
    }
×
431

432
    /// Update the config based on overrides from the CLI.
433
    ///
434
    /// The `--cfg` global flag is handled separately, this is specifically for
435
    /// "convenience" flags such as `--persona` or `--context`, which are
436
    /// equivalent to `--cfg conversation.persona` and `--cfg
437
    /// conversation.context`.
438
    fn update_config(&self, config: &mut jp_config::Config) -> Result<()> {
×
439
        // Hide reasoning.
440
        if self.hide_reasoning {
×
441
            config.style.reasoning.show = false;
×
442
        }
×
443

444
        // Update the conversation context.
445
        if let Some(context) = self.context.as_ref() {
×
446
            config.conversation.context = Some(context.clone());
×
447
        }
×
448

449
        // Update the persona.
450
        if let Some(persona) = self.persona.as_ref() {
×
451
            config.conversation.persona = Some(persona.clone());
×
452
        }
×
453

454
        // Update the model parameters.
455
        for KeyValue(key, value) in &self.parameters {
×
456
            config
×
457
                .llm
×
458
                .model
×
459
                .parameters
×
460
                .get_or_insert_default()
×
461
                .set(key, value.to_owned())?;
×
462
        }
463

464
        Ok(())
×
465
    }
×
466

467
    fn get_conversation_id(&self, ctx: &mut Ctx) -> Result<ConversationId> {
×
468
        if !self.new_conversation {
×
469
            return Ok(ctx.workspace.active_conversation_id());
×
470
        }
×
471

472
        let id = ctx.workspace.create_conversation(Conversation {
×
473
            local: self.local,
×
474
            ..Default::default()
×
475
        });
×
476

477
        debug!(
×
478
            %id,
479
            local = %self.local,
480
            "Creating new active conversation due to --new flag."
×
481
        );
482

483
        ctx.workspace.set_active_conversation_id(id)?;
×
484

485
        Ok(id)
×
486
    }
×
487

488
    // Open the editor for the query, if requested.
489
    fn edit_message(
×
490
        &self,
×
491
        message: &mut UserMessage,
×
492
        ctx: &mut Ctx,
×
493
        conversation_id: ConversationId,
×
494
        model: &Model,
×
495
    ) -> Result<Option<PathBuf>> {
×
496
        let UserMessage::Query(query) = message else {
×
497
            return Ok(None);
×
498
        };
499

500
        let mut editor = Editor::from_cli_or_config(self.edit.clone(), ctx.config.editor.clone());
×
501

502
        // Explicitly disable editing if the `--no-edit` flag is set.
503
        if self.no_edit || self.query.as_ref().is_some_and(|_| self.edit.is_none()) {
×
504
            editor = Some(Editor::Disabled);
×
505
        }
×
506

507
        let editor = match editor {
×
508
            None => return Ok(None),
×
509
            Some(Editor::Default) => unreachable!("handled in `from_cli_or_config`"),
×
510
            // If editing is disabled, we set the query as a single whitespace,
511
            // which allows the query to pass through to the assistant.
512
            Some(Editor::Disabled) => {
513
                if query.is_empty() {
×
514
                    " ".clone_into(query);
×
515
                }
×
516
                return Ok(None);
×
517
            }
518
            Some(cmd @ Editor::Command(_)) => match cmd.command() {
×
519
                Some(cmd) => cmd,
×
520
                None => return Ok(None),
×
521
            },
522
        };
523

524
        let initial_message = if query.is_empty() {
×
525
            None
×
526
        } else {
527
            Some(query.to_owned())
×
528
        };
529

530
        // If replaying, pass the last query as the text to be edited,
531
        // otherwise open an empty editor.
532
        let query_file_path;
533
        (*query, query_file_path) =
×
534
            editor::edit_query(ctx, conversation_id, model, initial_message, editor)
×
535
                .map(|(q, p)| (q, Some(p)))?;
×
536

537
        Ok(query_file_path)
×
538
    }
×
539

540
    /// Get the model to use for the current query.
541
    ///
542
    /// 1. If the `model` CLI flag is set, use that.
543
    /// 2. If the current persona has a model, use that.
544
    /// 3. If a model is configured in a configuration file or environment
545
    ///    variable, use that.
546
    /// 4. Otherwise return an error.
547
    fn get_model(&self, ctx: &Ctx) -> Result<Model> {
×
548
        let persona_id = &ctx.workspace.get_active_conversation().context.persona_id;
×
549
        let persona = ctx
×
550
            .workspace
×
551
            .get_persona(persona_id)
×
552
            .ok_or(Error::NotFound("Persona", persona_id.to_string()))?;
×
553

554
        let Some(id) = self
×
555
            .model
×
556
            .clone()
×
557
            .or_else(|| persona.model.clone())
×
558
            .or_else(|| ctx.config.llm.model.id.clone())
×
559
        else {
560
            return Err(Error::UndefinedModel);
×
561
        };
562

563
        let mut parameters = ctx.config.llm.model.parameters.clone().unwrap_or_default();
×
564
        if persona.inherit_parameters {
×
565
            parameters.merge(persona.parameters.clone());
×
566
        } else {
×
567
            parameters = persona.parameters.clone();
×
568
        }
×
569

570
        let model = Model { id, parameters };
×
571

572
        trace!(provider = %model.id.provider(), slug = %model.id.slug(), "Loaded LLM model.");
×
573

574
        Ok(model)
×
575
    }
×
576

577
    fn tool_choice(&self, ctx: &Ctx) -> ToolChoice {
×
578
        self.no_tool_choice
×
579
            .then_some(ToolChoice::None)
×
580
            .or_else(|| {
×
581
                self.tool_choice.as_ref().map(|v| match v.as_deref() {
×
582
                    None | Some("true") => ToolChoice::Required,
×
583
                    Some(v) => match v {
×
584
                        "false" => ToolChoice::None,
×
585
                        _ => ToolChoice::Function(v.to_owned()),
×
586
                    },
587
                })
×
588
            })
×
589
            .or_else(|| {
×
590
                ctx.workspace
×
591
                    .get_active_conversation()
×
592
                    .context
×
593
                    .tool_choice
×
594
                    .clone()
×
595
            })
×
596
            .or_else(|| ctx.config.llm.tool_choice.clone())
×
597
            .unwrap_or(ToolChoice::Auto)
×
598
    }
×
599
}
600

601
fn build_thread(
×
602
    ctx: &Ctx,
×
603
    conversation_id: ConversationId,
×
604
    attachments: Vec<jp_attachment::Attachment>,
×
605
    persona: &Persona,
×
606
    message: UserMessage,
×
607
    tools: &[Tool],
×
608
) -> Result<Thread> {
×
609
    let mut thread_builder = ThreadBuilder::default()
×
610
        .with_system_prompt(persona.system_prompt.clone())
×
611
        .with_instructions(persona.instructions.clone())
×
612
        .with_attachments(attachments)
×
613
        .with_history(ctx.workspace.get_messages(&conversation_id).to_vec())
×
614
        .with_message(message);
×
615

616
    if !tools.is_empty() {
×
617
        let instruction = Instructions::default()
×
618
            .with_title("Tool Usage")
×
619
            .with_description("How to leverage the tools available to you.".to_string())
×
620
            .with_item("Use all the tools available to you to give the best possible answer.")
×
621
            .with_item("Verify the tool name, description and parameters are correct.")
×
622
            .with_item(
×
623
                "Even if you've reasoned yourself towards a solution, use any available tool to \
×
624
                 verify your answer.",
×
625
            );
×
626

×
627
        thread_builder = thread_builder.with_instruction(instruction);
×
628
    }
×
629

630
    Ok(thread_builder.build()?)
×
631
}
×
632

633
async fn handle_structured_output(
×
634
    ctx: &mut Ctx,
×
635
    context: Context,
×
636
    thread: Thread,
×
637
    model: &Model,
×
638
    schema: schemars::Schema,
×
639
) -> Result<MessagePair> {
×
640
    let provider = provider::get_provider(model.id.provider(), &ctx.config.llm.provider)?;
×
641
    let message = thread.message.clone();
×
642
    let query =
×
643
        StructuredQuery::new(schema, thread).map_err(|err| Error::Schema(err.to_string()))?;
×
644

645
    let value = provider.structured_completion(model, query).await?;
×
646
    let content = if ctx.term.is_tty {
×
647
        serde_json::to_string_pretty(&value)?
×
648
    } else {
649
        serde_json::to_string(&value)?
×
650
    };
651

652
    Ok(MessagePair::new(message, AssistantMessage::from(content)).with_context(context))
×
653
}
×
654

655
#[expect(clippy::needless_pass_by_value)]
656
fn json_schema(s: String) -> Result<schemars::Schema> {
×
657
    serde_json::from_str::<serde_json::Value>(&s)?
×
658
        .try_into()
×
659
        .map_err(Into::into)
×
660
}
×
661

662
fn string_or_path(s: &str) -> Result<String> {
×
663
    if let Some(s) = s
×
664
        .strip_prefix(PATH_STRING_PREFIX)
×
665
        .and_then(|s| expand_tilde(s, env::var("HOME").ok()))
×
666
    {
667
        return fs::read_to_string(s).map_err(Into::into);
×
668
    }
×
669

670
    Ok(s.to_owned())
×
671
}
×
672

673
#[expect(clippy::too_many_lines)]
674
async fn handle_stream(
×
675
    ctx: &mut Ctx,
×
676
    context: Context,
×
677
    mut thread: Thread,
×
678
    model: &Model,
×
679
    tools: Vec<Tool>,
×
680
    tool_choice: ToolChoice,
×
681
    messages: &mut Vec<MessagePair>,
×
682
    stream_mode: StreamMode,
×
683
) -> Result<()> {
×
684
    let provider = provider::get_provider(model.id.provider(), &ctx.config.llm.provider)?;
×
685
    let message = thread.message.clone();
×
686
    let query = ChatQuery {
×
687
        thread: thread.clone(),
×
688

689
        // Limit the tools to the ones that are relevant to the tool choice.
690
        tools: match &tool_choice {
×
691
            ToolChoice::None => vec![],
×
692
            ToolChoice::Auto | ToolChoice::Required => tools.clone(),
×
693
            ToolChoice::Function(name) => tools
×
694
                .clone()
×
695
                .into_iter()
×
696
                .filter(|v| &v.name == name)
×
697
                .collect(),
×
698
        },
699
        tool_choice,
×
700
        ..Default::default()
×
701
    };
702
    let mut stream = provider.chat_completion_stream(model, query).await?;
×
703

704
    let mut content_tokens = String::new();
×
705
    let mut reasoning_tokens = String::new();
×
706
    let mut handler = ResponseHandler::new(stream_mode);
×
707
    let mut metadata = BTreeMap::new();
×
708
    let mut tool_calls = Vec::new();
×
709
    let mut tool_call_results = Vec::new();
×
710

711
    while let Some(event) = stream.next().await {
×
712
        let data = match event? {
×
713
            StreamEvent::ChatChunk(chunk) => match chunk {
×
714
                CompletionChunk::Reasoning(data) if !data.is_empty() => {
×
715
                    reasoning_tokens.push_str(&data);
×
716

717
                    if !ctx.config.style.reasoning.show {
×
718
                        continue;
×
719
                    }
×
720

721
                    data
×
722
                }
723
                CompletionChunk::Content(mut data) if !data.is_empty() => {
×
724
                    let reasoning_ended = !reasoning_tokens.is_empty()
×
725
                        && ctx.config.style.reasoning.show
×
726
                        && content_tokens.is_empty();
×
727

728
                    content_tokens.push_str(&data);
×
729

730
                    // If the response includes reasoning, we add two newlines
731
                    // after the reasoning, but before the content.
732
                    if reasoning_ended {
×
733
                        data = format!("\n\n{data}");
×
734
                    }
×
735

736
                    data
×
737
                }
738
                _ => continue,
×
739
            },
740
            // Tool calls are handled after the stream is finished.
741
            //
742
            // We do add a history of the call to the content tokens for the
743
            // LLMs understanding, but we do not print it to the terminal.
744
            StreamEvent::ToolCall(call) => {
×
745
                tool_calls.push(call.clone());
×
746

747
                let data = indoc::formatdoc!(
×
748
                    "
×
749
                    ---
×
750
                    executing tool: **{}**
×
751

×
752
                    arguments:
×
753
                    ```json
×
754
                    {:#}
×
755
                    ```
×
756

×
757
                ",
×
758
                    call.name,
759
                    call.arguments
760
                );
761

762
                handler.handle(&data, ctx)?;
×
763
                let result = handle_tool_call(ctx, call.clone()).await?;
×
764
                tool_call_results.push(result.clone());
×
765

766
                let content = if result.content.starts_with("```") {
×
767
                    result.content
×
768
                } else {
769
                    format!("```\n{}\n```", result.content)
×
770
                };
771

772
                indoc::formatdoc! {"
×
773
                    result:
×
774

×
775
                    {content}
×
776
                    ---
×
777
                    "
×
778
                }
779
            }
780
            StreamEvent::Metadata(key, data) => {
×
781
                metadata.insert(key, data);
×
782
                continue;
×
783
            }
784
        };
785

786
        handler.handle(&data, ctx)?;
×
787
    }
788

789
    // Ensure we handle the last line of the stream.
790
    if !handler.buffer.is_empty() {
×
791
        handler.handle("\n", ctx)?;
×
792
    }
×
793

794
    let content_tokens = content_tokens.trim().to_string();
×
795
    let content = if !content_tokens.is_empty() {
×
796
        Some(content_tokens)
×
797
    } else if content_tokens.is_empty() && tool_calls.is_empty() {
×
798
        Some("<no reply>".to_string())
×
799
    } else {
800
        None
×
801
    };
802

803
    let reasoning_tokens = reasoning_tokens.trim().to_string();
×
804
    let reasoning = if reasoning_tokens.is_empty() {
×
805
        None
×
806
    } else {
807
        Some(reasoning_tokens)
×
808
    };
809

810
    if let StreamMode::Forced(false) = handler.mode {
×
811
        println!("{}", handler.parsed.join("\n"));
×
812
    } else if content.is_some() || reasoning.is_some() {
×
813
        // Final newline.
×
814
        println!();
×
815
    }
×
816

817
    let message = MessagePair::new(message, AssistantMessage {
×
818
        metadata,
×
819
        content,
×
820
        reasoning,
×
821
        tool_calls: tool_calls.clone(),
×
822
    })
×
823
    .with_context(context.clone());
×
824
    messages.push(message.clone());
×
825

826
    // If the assistant asked for a tool call, we handle it automatically,
827
    // essentially going into a "loop" until no more tool calls are
828
    // requested.
829
    //
830
    // TODO:
831
    //
832
    // This should be handled differently, asking for permission to run a
833
    // tool (unless whitelisted per conversation/globally), it should log
834
    // the fact that a tool call is triggered, and it should guard against
835
    // infinite loops.
836
    if !tool_call_results.is_empty() {
×
837
        thread.history.push(message);
×
838
        thread.message = UserMessage::ToolCallResults(tool_call_results);
×
839

840
        Box::pin(handle_stream(
×
841
            ctx,
×
842
            context,
×
843
            thread,
×
844
            model,
×
845
            tools,
×
846
            // After the first tool call, we revert back to letting the LLM
×
847
            // decide if/which tool to use.
×
848
            ToolChoice::Auto,
×
849
            messages,
×
850
            stream_mode,
×
851
        ))
×
852
        .await?;
×
853
    }
×
854

855
    Ok(())
×
856
}
×
857

858
async fn handle_tool_calls(
×
859
    ctx: &Ctx,
×
860
    tool_calls: Vec<ToolCallRequest>,
×
861
) -> Result<Vec<ToolCallResult>> {
×
862
    let mut results = vec![];
×
863
    for call in tool_calls {
×
864
        results.push(handle_tool_call(ctx, call).await?);
×
865
    }
866

867
    Ok(results)
×
868
}
×
869

870
async fn handle_tool_call(ctx: &Ctx, call: ToolCallRequest) -> Result<ToolCallResult> {
×
871
    info!(tool = %call.name, arguments = %call.arguments, "Calling tool.");
×
872

873
    let result = ctx.mcp_client.call_tool(&call.name, call.arguments).await?;
×
874
    trace!(result = ?result, "Tool call completed.");
×
875

876
    Ok(ToolCallResult {
877
        id: call.id,
×
878
        error: result.is_error.unwrap_or(false),
×
879
        content: result
×
880
            .content
×
881
            .into_iter()
×
882
            .filter_map(|c| match c.raw {
×
883
                jp_mcp::RawContent::Text(text_content) => Some(text_content.text),
×
884
                jp_mcp::RawContent::Resource(embedded_resource) => {
×
885
                    match embedded_resource.resource {
×
886
                        ResourceContents::TextResourceContents { text, .. } => Some(text),
×
887
                        ResourceContents::BlobResourceContents { .. } => None,
×
888
                    }
889
                }
890
                _ => None,
×
891
            })
×
892
            .collect::<Vec<_>>()
×
893
            .join("\n\n"),
×
894
    })
895
}
×
896

897
struct Line {
898
    content: String,
899
    variant: LineVariant,
900
}
901

902
#[derive(Debug)]
903
enum LineVariant {
904
    Normal,
905
    Code,
906
    FencedCodeBlockStart { language: Option<String> },
907
    FencedCodeBlockEnd { indent: usize },
908
}
909

910
impl Line {
911
    fn new(content: String, in_fenced_code_block: bool) -> Self {
×
912
        let variant = if in_fenced_code_block && content.trim().ends_with("```") {
×
913
            let indent = content.chars().take_while(|c| c.is_whitespace()).count();
×
914

915
            LineVariant::FencedCodeBlockEnd { indent }
×
916
        } else if content.trim_start().starts_with("```") {
×
917
            let language = content
×
918
                .trim_start()
×
919
                .chars()
×
920
                .skip(3)
×
921
                .take_while(|c| c.is_alphanumeric())
×
922
                .collect::<String>();
×
923
            let language = if language.is_empty() {
×
924
                None
×
925
            } else {
926
                Some(language)
×
927
            };
928

929
            LineVariant::FencedCodeBlockStart { language }
×
930
        } else if in_fenced_code_block {
×
931
            LineVariant::Code
×
932
        } else {
933
            LineVariant::Normal
×
934
        };
935

936
        Line { content, variant }
×
937
    }
×
938
}
939

940
#[derive(Debug, Default)]
941
struct ResponseHandler {
942
    /// Whether the response should be streamed.
943
    mode: StreamMode,
944

945
    /// The streamed, unprocessed lines received from the LLM.
946
    received: Vec<String>,
947

948
    /// The lines that have been parsed so far.
949
    ///
950
    /// If `should_stream` is `true`, these lines have been printed to the
951
    /// terminal. Otherwise they will be printed when the response handler is
952
    /// finished.
953
    parsed: Vec<String>,
954

955
    /// A temporary buffer of data received from the LLM.
956
    buffer: String,
957

958
    in_fenced_code_block: bool,
959
    // (language, code)
960
    code_buffer: (Option<String>, Vec<String>),
961
    code_line: usize,
962

963
    // The last index of the line that ends a code block.
964
    // (streamed, printed)
965
    last_fenced_code_block_end: (usize, usize),
966
}
967

968
impl ResponseHandler {
969
    fn new(mode: StreamMode) -> Self {
×
970
        Self {
×
971
            mode,
×
972
            ..Default::default()
×
973
        }
×
974
    }
×
975

976
    fn handle(&mut self, data: &str, ctx: &Ctx) -> Result<()> {
×
977
        self.buffer.push_str(data);
×
978

979
        while let Some(Line { content, variant }) = self.get_line() {
×
980
            self.received.push(content);
×
981

982
            let delay = match variant {
×
983
                LineVariant::Code => ctx.config.style.typewriter.code_delay,
×
984
                _ => ctx.config.style.typewriter.text_delay,
×
985
            };
986

987
            let lines = self.handle_line(&variant, ctx)?;
×
988

989
            if !matches!(self.mode, StreamMode::Forced(false)) {
×
990
                stdout::typewriter(&lines.join("\n"), delay)?;
×
991
            }
×
992

993
            self.parsed.extend(lines);
×
994
        }
995

996
        Ok(())
×
997
    }
×
998

999
    #[expect(clippy::too_many_lines)]
1000
    fn handle_line(&mut self, variant: &LineVariant, ctx: &Ctx) -> Result<Vec<String>> {
×
1001
        let Some(content) = self.received.last().map(String::as_str) else {
×
1002
            return Ok(vec![]);
×
1003
        };
1004

1005
        match variant {
×
1006
            LineVariant::Code => {
1007
                self.code_line += 1;
×
1008
                self.code_buffer.1.push(content.to_owned());
×
1009

1010
                let mut buf = String::new();
×
1011
                let config = code::Config {
×
1012
                    language: self.code_buffer.0.clone(),
×
1013
                    theme: ctx
×
1014
                        .config
×
1015
                        .style
×
1016
                        .code
×
1017
                        .color
×
1018
                        .then(|| ctx.config.style.code.theme.clone()),
×
1019
                };
1020

1021
                if !code::format(content, &mut buf, &config)? {
×
1022
                    let config = code::Config {
×
1023
                        language: None,
×
1024
                        theme: config.theme,
×
1025
                    };
×
1026

1027
                    code::format(content, &mut buf, &config)?;
×
1028
                }
×
1029

1030
                if ctx.config.style.code.line_numbers {
×
1031
                    buf.insert_str(
×
1032
                        0,
×
1033
                        &format!("{:2} │ ", self.code_line)
×
1034
                            .with(Color::AnsiValue(238))
×
1035
                            .to_string(),
×
1036
                    );
×
1037
                }
×
1038

1039
                Ok(vec![buf])
×
1040
            }
1041
            LineVariant::FencedCodeBlockStart { language } => {
×
1042
                self.code_buffer.0.clone_from(language);
×
1043
                self.code_buffer.1.clear();
×
1044
                self.code_line = 0;
×
1045
                self.in_fenced_code_block = true;
×
1046

1047
                Ok(vec![content.with(Color::AnsiValue(238)).to_string()])
×
1048
            }
1049
            LineVariant::FencedCodeBlockEnd { indent } => {
×
1050
                self.last_fenced_code_block_end = (self.received.len(), self.parsed.len() + 2);
×
1051

1052
                let path = self.persist_code_block()?;
×
1053
                let mut links = vec![];
×
1054

1055
                match ctx.config.style.code.file_link {
×
1056
                    LinkStyle::Off => {}
×
1057
                    LinkStyle::Full => {
×
1058
                        links.push(format!(
×
1059
                            "{}see: file://{}",
×
1060
                            " ".repeat(*indent),
×
1061
                            path.display()
×
1062
                        ));
×
1063
                    }
×
1064
                    LinkStyle::Osc8 => {
×
1065
                        links.push(format!(
×
1066
                            "{}[{}]",
×
1067
                            " ".repeat(*indent),
×
1068
                            hyperlink(
×
1069
                                format!("file://{}", path.display()),
×
1070
                                "open in editor".red().to_string()
×
1071
                            )
×
1072
                        ));
×
1073
                    }
×
1074
                }
1075

1076
                match ctx.config.style.code.copy_link {
×
1077
                    LinkStyle::Off => {}
×
1078
                    LinkStyle::Full => {
×
1079
                        links.push(format!(
×
1080
                            "{}copy: copy://{}",
×
1081
                            " ".repeat(*indent),
×
1082
                            path.display()
×
1083
                        ));
×
1084
                    }
×
1085
                    LinkStyle::Osc8 => {
×
1086
                        links.push(format!(
×
1087
                            "{}[{}]",
×
1088
                            " ".repeat(*indent),
×
1089
                            hyperlink(
×
1090
                                format!("copy://{}", path.display()),
×
1091
                                "copy to clipboard".red().to_string()
×
1092
                            )
×
1093
                        ));
×
1094
                    }
×
1095
                }
1096

1097
                self.in_fenced_code_block = false;
×
1098

1099
                let mut lines = vec![content.with(Color::AnsiValue(238)).to_string()];
×
1100
                if !links.is_empty() {
×
1101
                    lines.push(links.join(" "));
×
1102
                }
×
1103

1104
                Ok(lines)
×
1105
            }
1106
            LineVariant::Normal => {
1107
                // We feed all the lines for markdown formatting, but only
1108
                // print the last one, as the others are already printed.
1109
                //
1110
                // This helps the parser to use previous context to apply
1111
                // the correct formatting to the current line.
1112
                //
1113
                // We only care about the lines after the last code block
1114
                // end, because a) formatting context is reset after a code
1115
                // block, and b) we dot not limit the line length of code, makes
1116
                // it impossible to correctly find the non-printed lines based
1117
                // on wrapped vs non-wrapped lines.
1118
                let lines = self
×
1119
                    .received
×
1120
                    .iter()
×
1121
                    .skip(self.last_fenced_code_block_end.0)
×
1122
                    .cloned()
×
1123
                    .collect::<Vec<_>>();
×
1124

1125
                // `termimad` removes empty lines at the start or end, but we
1126
                // want to keep them as we will have more lines to print.
1127
                let empty_lines_start_count = lines.iter().take_while(|s| s.is_empty()).count();
×
1128
                let empty_lines_end_count = lines.iter().rev().take_while(|s| s.is_empty()).count();
×
1129

1130
                let options = comrak::Options {
×
1131
                    render: comrak::RenderOptions {
×
1132
                        unsafe_: true,
×
1133
                        prefer_fenced: true,
×
1134
                        experimental_minimize_commonmark: true,
×
1135
                        ..Default::default()
×
1136
                    },
×
1137
                    ..Default::default()
×
1138
                };
×
1139

1140
                let formatted = comrak::markdown_to_commonmark(&lines.join("\n"), &options);
×
1141

1142
                let mut formatted =
×
1143
                    FmtText::from(&termimad::MadSkin::default(), &formatted, Some(100)).to_string();
×
1144

1145
                for _ in 0..empty_lines_start_count {
×
1146
                    formatted.insert(0, '\n');
×
1147
                }
×
1148

1149
                // Only add an extra newline if we have more than one line,
1150
                // otherwise a single empty line will be interpreted as both a
1151
                // missing start and end newline.
1152
                if lines.iter().any(|s| !s.is_empty()) {
×
1153
                    for _ in 0..empty_lines_end_count {
×
1154
                        formatted.push('\n');
×
1155
                    }
×
1156
                }
×
1157

1158
                let lines = formatted
×
1159
                    .lines()
×
1160
                    .skip(self.parsed.len() - self.last_fenced_code_block_end.1)
×
1161
                    .map(ToOwned::to_owned)
×
1162
                    .collect::<Vec<_>>();
×
1163

1164
                Ok(lines)
×
1165
            }
1166
        }
1167
    }
×
1168

1169
    fn get_line(&mut self) -> Option<Line> {
×
1170
        let s = &mut self.buffer;
×
1171
        let idx = s.find('\n')?;
×
1172

1173
        // Determine the end index of the actual line *content*.
1174
        // Check if the character before '\n' is '\r'.
1175
        let end_idx = if idx > 0 && s.as_bytes().get(idx - 1) == Some(&b'\r') {
×
1176
            idx - 1
×
1177
        } else {
1178
            idx
×
1179
        };
1180

1181
        // Extract the line content *before* draining.
1182
        // Creating a slice and then converting to owned String.
1183
        let extracted_line = s[..end_idx].to_string();
×
1184

1185
        // Calculate the index *after* the newline sequence to drain up to.
1186
        // This ensures we remove the '\n' and potentially the preceding '\r'.
1187
        let drain_end_idx = idx + 1;
×
1188
        s.drain(..drain_end_idx);
×
1189

1190
        Some(Line::new(extracted_line, self.in_fenced_code_block))
×
1191
    }
×
1192

1193
    fn persist_code_block(&self) -> Result<PathBuf> {
×
1194
        let code = self.code_buffer.1.clone();
×
1195
        let language = self.code_buffer.0.as_deref().unwrap_or("txt");
×
1196
        let ext = match language {
×
1197
            "c++" => "cpp",
×
1198
            "javascript" => "js",
×
1199
            "python" => "py",
×
1200
            "ruby" => "rb",
×
1201
            "rust" => "rs",
×
1202
            "typescript" => "ts",
×
1203
            lang => lang,
×
1204
        };
1205

1206
        let millis = std::time::SystemTime::now()
×
1207
            .duration_since(std::time::UNIX_EPOCH)
×
1208
            .unwrap_or_default()
×
1209
            .subsec_millis();
×
1210
        let path = std::env::temp_dir().join(format!("code_{millis}.{ext}"));
×
1211

1212
        fs::write(&path, code.join("\n"))?;
×
1213

1214
        Ok(path)
×
1215
    }
×
1216
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc