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

dcdpr / jp / 20124258614

11 Dec 2025 06:34AM UTC coverage: 47.733% (-1.2%) from 48.957%
20124258614

push

github

web-flow
refactor(conversation)!: Replace `MessagePair` with `ConversationEvent` (#237)

This refactors the core conversation model to use a stream of events
instead of a list of message pairs. The `MessagePair` struct, which
coupled a user message with an assistant reply, is removed in favor of
distinct `ConversationEvents` for user and assistant messages.

This change provides a more flexible and extensible foundation for
conversation history. It paves the way for introducing more granular
event types in the future, such as tool call requests and results,
without being constrained to a rigid request/response structure.

BREAKING CHANGE: The `MessagePair` type has been removed and replaced
with the `ConversationEvent` enum. This fundamentally changes how
conversation history is structured, from a list of request-reply pairs
to a sequential stream of events.

The on-disk storage format for conversations is also changed from
`messages.json` to `events.json.` Existing conversation data is not
automatically migrated and will be incompatible.

---------

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

502 of 1652 new or added lines in 35 files covered. (30.39%)

289 existing lines in 40 files now uncovered.

8010 of 16781 relevant lines covered (47.73%)

13.92 hits per line

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

0.0
/crates/jp_cli/src/editor.rs
1
mod parser;
2

3
use std::{
4
    fs::{self, OpenOptions},
5
    io::{Read as _, Write as _},
6
    path::PathBuf,
7
    str::FromStr,
8
};
9

10
use duct::Expression;
11
use itertools::Itertools;
12
use jp_config::{
13
    AppConfig, PartialAppConfig, ToPartial as _, model::parameters::PartialReasoningConfig,
14
};
15
use jp_conversation::{
16
    ConversationStream,
17
    event::{ChatResponse, EventKind},
18
};
19
use time::{UtcOffset, macros::format_description};
20

21
use crate::{
22
    editor::parser::QueryDocument,
23
    error::{Error, Result},
24
};
25

26
/// The name of the file used to store the current query message.
27
const QUERY_FILENAME: &str = "QUERY_MESSAGE.md";
28

29
/// How to edit the query.
30
#[derive(Debug, Clone, PartialEq, Default)]
31
pub(crate) enum Editor {
32
    /// Use whatever editor is configured.
33
    #[default]
34
    Default,
35

36
    /// Use the given command.
37
    Command(String),
38

39
    /// Do not edit the query.
40
    Disabled,
41
}
42

43
impl FromStr for Editor {
44
    type Err = Error;
45

46
    fn from_str(s: &str) -> Result<Self> {
×
47
        match s {
×
48
            "true" => Ok(Self::Default),
×
49
            "false" => Ok(Self::Disabled),
×
50
            s => Ok(Self::Command(s.to_owned())),
×
51
        }
52
    }
×
53
}
54

55
/// Options for opening an editor.
56
#[derive(Debug)]
57
pub(crate) struct Options {
58
    cmd: Expression,
59

60
    /// The working directory to use.
61
    cwd: Option<PathBuf>,
62

63
    /// The initial content to use.
64
    content: Option<String>,
65

66
    /// Whether to force write the file, even if it already exists.
67
    force_write: bool,
68
}
69

70
impl Options {
71
    pub(crate) fn new(cmd: Expression) -> Self {
×
72
        Self {
×
73
            cmd,
×
74
            cwd: None,
×
75
            content: None,
×
76
            force_write: false,
×
77
        }
×
78
    }
×
79

80
    /// Add a working directory to the editor options.
81
    #[must_use]
82
    pub(crate) fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
×
83
        self.cwd = Some(cwd.into());
×
84
        self
×
85
    }
×
86

87
    /// Add content to the editor options.
88
    #[must_use]
89
    pub(crate) fn with_content(mut self, content: impl Into<String>) -> Self {
×
90
        self.content = Some(content.into());
×
91
        self
×
92
    }
×
93

94
    /// Force write the file, even if it already exists.
95
    #[must_use]
96
    pub(crate) fn with_force_write(mut self, force_write: bool) -> Self {
×
97
        self.force_write = force_write;
×
98
        self
×
99
    }
×
100
}
101

102
pub(crate) struct RevertFileGuard {
103
    path: Option<PathBuf>,
104
    orig: String,
105
    exists: bool,
106
}
107

108
impl RevertFileGuard {
109
    pub(crate) fn disarm(&mut self) {
×
110
        self.path.take();
×
111
    }
×
112
}
113

114
impl Drop for RevertFileGuard {
115
    fn drop(&mut self) {
×
116
        // No path, means this guard was disarmed.
117
        let Some(path) = &self.path else {
×
118
            return;
×
119
        };
120

121
        // File did not exist, so we remove it, and any empty parent
122
        // directories.
123
        if !self.exists {
×
124
            let _rm = fs::remove_file(path);
×
125
            let mut path = path.clone();
×
126
            loop {
127
                let Some(parent) = path.parent() else {
×
128
                    break;
×
129
                };
130

131
                let Ok(mut dir) = fs::read_dir(parent) else {
×
132
                    break;
×
133
                };
134

135
                if dir.next().is_some() {
×
136
                    break;
×
137
                }
×
138

139
                let _rm = fs::remove_dir(parent);
×
140
                path = parent.to_owned();
×
141
            }
142

143
            return;
×
144
        }
×
145

146
        // File existed, so we restore the original content.
147
        let _write = fs::write(path, &self.orig);
×
148
    }
×
149
}
150

151
/// Open an editor for the given file with the given content.
152
///
153
/// If the file exists, it will be opened, but the content will not be modified
154
/// (in other words, `content` is ignored).
155
///
156
/// When the editor is closed, the contents are returned.
157
pub(crate) fn open(path: PathBuf, options: Options) -> Result<(String, RevertFileGuard)> {
×
158
    let Options {
159
        cmd,
×
160
        cwd,
×
161
        content,
×
162
        force_write,
×
163
    } = options;
×
164

165
    let exists = path.exists();
×
166
    let guard = RevertFileGuard {
×
167
        path: Some(path.clone()),
×
168
        orig: fs::read_to_string(&path).unwrap_or_default(),
×
169
        exists,
×
170
    };
×
171

172
    let existing_content = fs::read_to_string(&path).unwrap_or_default();
×
173

174
    if !exists || existing_content.is_empty() || force_write {
×
175
        if let Some(parent) = path.parent() {
×
176
            fs::create_dir_all(parent)?;
×
177
        }
×
178

179
        let mut file = OpenOptions::new()
×
180
            .read(true)
×
181
            .write(true)
×
182
            .create(true)
×
183
            .truncate(true)
×
184
            .open(&path)?;
×
185

186
        let mut current_content = String::new();
×
187
        file.read_to_string(&mut current_content)?;
×
188

189
        file.write_all(content.unwrap_or_default().as_bytes())?;
×
190
        file.write_all(current_content.as_bytes())?;
×
191
    }
×
192

193
    // Open the editor
194
    let output = cmd
×
195
        .before_spawn({
×
196
            let path = path.clone();
×
197
            move |cmd| {
×
198
                cmd.arg(path.clone());
×
199

200
                if let Some(cwd) = &cwd {
×
201
                    cmd.current_dir(cwd);
×
202
                }
×
203

204
                Ok(())
×
205
            }
×
206
        })
207
        .unchecked()
×
208
        .run()?;
×
209

210
    let status = output.status;
×
211
    if !status.success() {
×
212
        return Err(Error::Editor(format!("Editor exited with error: {status}")));
×
213
    }
×
214

215
    // Read the edited content
216
    let content = fs::read_to_string(path)?;
×
217

218
    Ok((content, guard))
×
219
}
×
220

221
/// Open an editor for the user to input or edit text using a file in the workspace
222
pub(crate) fn edit_query(
×
NEW
223
    config: &AppConfig,
×
NEW
224
    root: PathBuf,
×
NEW
225
    stream: &ConversationStream,
×
NEW
226
    query: &str,
×
227
    cmd: Expression,
×
228
    config_error: Option<&str>,
×
229
) -> Result<(String, PathBuf, PartialAppConfig)> {
×
230
    let query_file_path = root.join(QUERY_FILENAME);
×
231
    let existing_content = fs::read_to_string(&query_file_path).unwrap_or_default();
×
232
    let mut doc = QueryDocument::try_from(existing_content.as_str()).unwrap_or_default();
×
233

NEW
234
    if doc.query.is_empty() {
×
NEW
235
        doc.query = query;
×
UNCOV
236
    }
×
237

NEW
238
    let config_value = build_config_text(config);
×
239
    if doc.meta.config.value.is_empty() {
×
240
        doc.meta.config.value = &config_value;
×
241
    }
×
242

243
    if let Some(error) = config_error {
×
244
        doc.meta.config.error = Some(error);
×
245
    }
×
246

NEW
247
    let history_value = build_history_text(stream);
×
248
    doc.meta.history.value = &history_value;
×
249

250
    let options = Options::new(cmd.clone())
×
NEW
251
        .with_cwd(&root)
×
252
        .with_content(doc)
×
253
        .with_force_write(true);
×
254

255
    let (content, mut guard) = open(query_file_path.clone(), options)?;
×
256

257
    let doc = QueryDocument::try_from(content.as_str()).unwrap_or_default();
×
NEW
258
    let mut partial = PartialAppConfig::empty();
×
259
    if !doc.meta.config.value.is_empty() {
×
260
        match toml::from_str::<PartialAppConfig>(doc.meta.config.value) {
×
NEW
261
            Ok(v) => partial = v,
×
262
            Err(error) => {
×
263
                let error = error.to_string();
×
NEW
264
                return edit_query(config, root, stream, "", cmd, Some(&error));
×
265
            }
266
        }
267
    }
×
268

269
    guard.disarm();
×
NEW
270
    Ok((doc.query.to_owned(), query_file_path, partial))
×
271
}
×
272

273
fn build_config_text(config: &AppConfig) -> String {
×
274
    let model_id = &config.assistant.model.id;
×
275
    let mut tools = config
×
276
        .conversation
×
277
        .tools
×
278
        .iter()
×
279
        .filter_map(|(k, cfg)| cfg.enable().then_some(k))
×
280
        .sorted()
×
281
        .collect::<Vec<_>>()
×
282
        .join(", ");
×
283

284
    if tools.is_empty() {
×
285
        tools = "(none)".to_owned();
×
286
    }
×
287

288
    let mut active_config = PartialAppConfig::empty();
×
289
    active_config.assistant.model.id = model_id.to_partial();
×
290
    active_config.assistant.model.parameters.reasoning = config
×
291
        .assistant
×
292
        .model
×
293
        .parameters
×
294
        .reasoning
×
295
        .map(|v| v.to_partial())
×
296
        .or(Some(PartialReasoningConfig::Auto));
×
297

298
    toml::to_string_pretty(&active_config).unwrap_or_default()
×
299
}
×
300

NEW
301
fn build_history_text(history: &ConversationStream) -> String {
×
302
    let mut text = String::new();
×
303

304
    if !history.is_empty() {
×
305
        text.push_str("\n# Conversation History");
×
306
    }
×
307

308
    let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
×
309
    let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]");
×
310

311
    let mut messages = vec![];
×
NEW
312
    for event in history.iter() {
×
313
        let mut buf = String::new();
×
NEW
314
        let timestamp = event
×
315
            .timestamp
×
316
            .to_offset(local_offset)
×
317
            .format(&format)
×
NEW
318
            .unwrap_or_else(|_| event.timestamp.to_string());
×
319

320
        let options = comrak::Options {
×
321
            render: comrak::RenderOptions {
×
322
                width: 80,
×
323
                unsafe_: true,
×
324
                prefer_fenced: true,
×
325
                experimental_minimize_commonmark: true,
×
326
                ..Default::default()
×
327
            },
×
328
            ..Default::default()
×
329
        };
×
330

NEW
331
        match &event.kind {
×
NEW
332
            EventKind::ChatRequest(request) => {
×
NEW
333
                buf.push_str(&format!("## You on {timestamp}\n\n"));
×
NEW
334
                buf.push_str(comrak::markdown_to_commonmark(&request.content, &options).trim());
×
335
            }
×
NEW
336
            EventKind::ChatResponse(response) => match response {
×
NEW
337
                ChatResponse::Message { message } => {
×
NEW
338
                    buf.push_str("\n\n## Assistant");
×
NEW
339
                    buf.push_str(&format!(" ({})", event.config.assistant.model.id));
×
NEW
340
                    buf.push_str(&format!(" on {timestamp}\n\n"));
×
NEW
341
                    buf.push_str(comrak::markdown_to_commonmark(message, &options).trim());
×
NEW
342
                }
×
NEW
343
                ChatResponse::Reasoning { reasoning, .. } => {
×
NEW
344
                    buf.push_str("\n\n## Assistant (reasoning)");
×
NEW
345
                    buf.push_str(&format!(" ({})", event.config.assistant.model.id));
×
NEW
346
                    buf.push_str(&format!(" on {timestamp}\n\n"));
×
NEW
347
                    buf.push_str(comrak::markdown_to_commonmark(reasoning, &options).trim());
×
NEW
348
                }
×
349
            },
NEW
350
            EventKind::ToolCallRequest(request) => {
×
NEW
351
                if let Ok(json) = serde_json::to_string_pretty(request) {
×
NEW
352
                    buf.push_str(&format!("\n\n## Tool Call Request on {timestamp}\n\n"));
×
NEW
353
                    buf.push_str("```json\n");
×
NEW
354
                    buf.push_str(&json);
×
355
                    buf.push_str("\n```");
×
356
                }
×
357
            }
NEW
358
            EventKind::ToolCallResponse(response) => {
×
NEW
359
                if response.result.is_ok() {
×
NEW
360
                    buf.push_str(&format!("\n\n## Tool Call Result on {timestamp}\n\n"));
×
NEW
361
                } else {
×
NEW
362
                    buf.push_str(&format!("\n\n## Tool Call **Error** on {timestamp}\n\n"));
×
NEW
363
                }
×
NEW
364
                buf.push_str("```\n");
×
NEW
365
                buf.push_str(&response.result.clone().unwrap_or_else(|err| err));
×
NEW
366
                buf.push_str("\n```");
×
367
            }
NEW
368
            EventKind::InquiryRequest(request) => {
×
NEW
369
                buf.push_str(&format!(
×
NEW
370
                    "\n\n## Inquiry Request ({:?}) on {timestamp}\n\n",
×
NEW
371
                    request.source
×
NEW
372
                ));
×
NEW
373
                buf.push_str(&request.question.text);
×
NEW
374
            }
×
NEW
375
            EventKind::InquiryResponse(response) => {
×
NEW
376
                buf.push_str(&format!("\n\n## Inquiry Response on {timestamp}\n\n"));
×
NEW
377
                buf.push_str("Answer: ");
×
NEW
378
                buf.push_str(&response.answer.to_string());
×
NEW
379
            }
×
380
        }
381

NEW
382
        buf.push_str("\n\n");
×
383
        messages.push(buf);
×
384
    }
385

386
    messages.reverse();
×
387
    text.extend(messages);
×
388
    text
×
389
}
×
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