Skip to content

Commit ef98ee5

Browse files
authored
fix(workspace, cli): Fix config loss on resume (#459)
Continuing a conversation without passing `--cfg` flags caused the conversation's model and tools to be silently reset to defaults on each invocation. The root cause was a sequencing issue: `apply_conversation_config` runs inside `load_partial_config` and needs to access the active conversation's stream config from disk. Previously, `load_conversations_from_disk` was called _after_ `load_partial_config`, so events weren't loaded yet. The merge fell back to an empty partial and overwrote whatever the conversation had stored. The fix splits the old `load_conversations_from_disk` into two methods. `load_conversation_index` loads conversation IDs, metadata, and `TombMap` entries — everything needed for lazy event access — and is now called _before_ `load_partial_config`. `ensure_active_conversation_stream` handles the one remaining config-dependent step (creating a default stream for fresh workspaces) and runs _after_ the final `AppConfig` is built. Signed-off-by: Jean Mertz <git@jeanmertz.com>
1 parent 006b0fa commit ef98ee5

5 files changed

Lines changed: 205 additions & 34 deletions

File tree

crates/jp_cli/src/lib.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,11 +358,20 @@ fn run_inner(cli: Cli, format: OutputFormat) -> Result<()> {
358358
}
359359
}
360360

361+
// Load conversation IDs and metadata from disk so that
362+
// `apply_conversation_config` (called inside `load_partial_config`) can
363+
// access conversation events via lazy loading.
364+
if let Err(error) = workspace.load_conversation_index() {
365+
tracing::error!(error = ?error, "Failed to load conversation index.");
366+
}
367+
361368
let partial = load_partial_config(&cli.command, Some(&workspace), &cli.globals.config)?;
362369
let config = Arc::new(build(partial)?);
363370

364-
if let Err(error) = workspace.load_conversations_from_disk(config.clone()) {
365-
tracing::error!(error = ?error, "Failed to load workspace.");
371+
// For fresh workspaces, create a default stream now that we have the
372+
// final config.
373+
if let Err(error) = workspace.ensure_active_conversation_stream(config.clone()) {
374+
tracing::error!(error = ?error, "Failed to ensure active conversation stream.");
366375
}
367376

368377
let runtime = build_runtime(cli.root.threads, "jp-worker")?;

crates/jp_workspace/src/lib.rs

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -154,31 +154,36 @@ impl Workspace {
154154
self.storage.as_ref().and_then(Storage::user_storage_path)
155155
}
156156

157-
/// Load the workspace state from the persisted storage.
157+
/// Load conversation IDs, metadata, and `TombMap` entries from disk.
158158
///
159-
/// If the workspace is not persisted, this method will return an error.
159+
/// This populates the workspace state so that conversation events can be
160+
/// accessed via [`get_events`](Self::get_events) (which triggers lazy
161+
/// loading from storage). For the active conversation, the stream is
162+
/// eagerly loaded for performance.
163+
///
164+
/// For fresh workspaces (no conversations on disk), this registers the
165+
/// active conversation ID in the events `TombMap` but does **not** create a
166+
/// default stream — call [`ensure_active_conversation_stream`] after
167+
/// the final [`AppConfig`] is available for that.
160168
///
161169
/// Call [`sanitize`](Self::sanitize) before this method to ensure the
162170
/// filesystem is in a consistent state.
163-
pub fn load_conversations_from_disk(&mut self, config: Arc<AppConfig>) -> Result<()> {
164-
trace!("Loading state.");
171+
///
172+
/// [`ensure_active_conversation_stream`]: Self::ensure_active_conversation_stream
173+
pub fn load_conversation_index(&mut self) -> Result<()> {
174+
trace!("Loading conversation index.");
165175

166-
let storage = self.storage.as_mut().ok_or(Error::MissingStorage)?;
176+
let storage = self.storage.as_ref().ok_or(Error::MissingStorage)?;
167177
let conversation_ids = storage.load_all_conversation_ids();
168178

169179
if conversation_ids.is_empty() {
170180
debug!("No conversations found, workspace is fresh.");
171181

172-
// Ensure the active conversation has an events entry so the
173-
// invariant (active_conversation_id → events) always holds.
182+
// Register the active conversation ID so that `get_events` can
183+
// find the entry (lazy loading will return `None` since there is
184+
// no file on disk, which is expected for fresh workspaces).
174185
let active_id = self.active_conversation_id();
175-
let _err = self
176-
.state
177-
.local
178-
.events
179-
.entry(active_id)
180-
.or_default()
181-
.set(ConversationStream::new(config).with_created_at(active_id.timestamp()));
186+
self.state.local.events.entry(active_id).or_default();
182187

183188
return Ok(());
184189
}
@@ -206,8 +211,7 @@ impl Workspace {
206211
.map(|id| (id, OnceCell::new()))
207212
.collect();
208213

209-
// We can `set` without checking if the cell is already initialized, as
210-
// we just initialized it above.let _err = events
214+
// Eagerly load the active conversation stream for performance.
211215
let _err = events
212216
.entry(metadata.active_conversation_id)
213217
.or_default()
@@ -227,6 +231,35 @@ impl Workspace {
227231
Ok(())
228232
}
229233

234+
/// Ensure the active conversation has an event stream.
235+
///
236+
/// For fresh workspaces where no stream exists on disk, this creates a
237+
/// default [`ConversationStream`] with the given config as its base.
238+
///
239+
/// For existing workspaces where the stream was already loaded (eagerly
240+
/// by [`load_conversation_index`] or lazily via [`get_events`]), this is
241+
/// a no-op.
242+
///
243+
/// [`load_conversation_index`]: Self::load_conversation_index
244+
/// [`get_events`]: Self::get_events
245+
pub fn ensure_active_conversation_stream(&mut self, config: Arc<AppConfig>) -> Result<()> {
246+
let active_id = self.active_conversation_id();
247+
248+
if self.get_events(&active_id).is_some() {
249+
return Ok(());
250+
}
251+
252+
let _err = self
253+
.state
254+
.local
255+
.events
256+
.entry(active_id)
257+
.or_default()
258+
.set(ConversationStream::new(config).with_created_at(active_id.timestamp()));
259+
260+
Ok(())
261+
}
262+
230263
/// Persists the current in-memory workspace state back to disk atomically.
231264
///
232265
/// This is a no-op if persistence is disabled.

crates/jp_workspace/src/lib_tests.rs

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ use std::{collections::HashMap, fs, time::Duration};
22

33
use camino_tempfile::tempdir;
44
use datetime_literal::datetime;
5+
use jp_config::{
6+
PartialConfig as _,
7+
fs::load_partial,
8+
model::id::{ModelIdOrAliasConfig, PartialModelIdOrAliasConfig, ProviderId},
9+
util::build,
10+
};
511
use jp_conversation::ConversationsMetadata;
612
use jp_storage::{
713
CONVERSATIONS_DIR, METADATA_FILE,
@@ -234,7 +240,7 @@ fn test_workspace_cannot_remove_active_conversation() {
234240
}
235241

236242
#[test]
237-
fn test_load_succeeds_when_no_conversations_exist() {
243+
fn test_load_index_fresh_workspace_then_ensure_stream() {
238244
let tmp = tempdir().unwrap();
239245
let root = tmp.path().join("root");
240246
let storage = root.join("storage");
@@ -247,16 +253,137 @@ fn test_load_succeeds_when_no_conversations_exist() {
247253
let mut workspace = Workspace::new(&root).persisted_at(&storage).unwrap();
248254
workspace.disable_persistence();
249255

250-
// A fresh workspace with no conversations on disk is valid — load
251-
// should succeed with default state.
252-
let config = Arc::new(AppConfig::new_test());
253-
workspace.load_conversations_from_disk(config).unwrap();
254-
255-
// The active conversation must have an events entry.
256+
// Phase 1: load index — no conversations on disk, so the active
257+
// conversation entry is registered but has no stream yet.
258+
workspace.load_conversation_index().unwrap();
256259
let active_id = workspace.active_conversation_id();
260+
assert!(
261+
workspace.get_events(&active_id).is_none(),
262+
"fresh workspace should have no stream before ensure_active_conversation_stream"
263+
);
264+
265+
// Phase 2: create the default stream with the final config.
266+
let config = Arc::new(AppConfig::new_test());
267+
workspace.ensure_active_conversation_stream(config).unwrap();
257268
assert!(workspace.get_events(&active_id).is_some());
258269
}
259270

271+
#[test]
272+
fn test_load_index_existing_workspace_events_accessible() {
273+
let tmp = tempdir().unwrap();
274+
let root = tmp.path().join("root");
275+
let storage_path = root.join("storage");
276+
277+
let config = Arc::new(AppConfig::new_test());
278+
let id = ConversationId::try_from(datetime!(2024-03-15 12:00:00 Z)).unwrap();
279+
280+
// Write a conversation to disk.
281+
{
282+
let mut ws = Workspace::new(&root).persisted_at(&storage_path).unwrap();
283+
ws.create_conversation_with_id(id, Conversation::default(), config.clone());
284+
ws.set_active_conversation_id(id, DateTime::<Utc>::UNIX_EPOCH)
285+
.unwrap();
286+
ws.persist().unwrap();
287+
}
288+
289+
// Reload from scratch — only load_conversation_index, no config needed.
290+
let mut ws = Workspace::new(&root).persisted_at(&storage_path).unwrap();
291+
ws.disable_persistence();
292+
ws.load_conversation_index().unwrap();
293+
294+
// Events should be accessible via lazy loading.
295+
assert_eq!(ws.active_conversation_id(), id);
296+
let events = ws.get_events(&id);
297+
assert!(
298+
events.is_some(),
299+
"events must be lazily loadable after load_conversation_index"
300+
);
301+
302+
// The stream's config should be retrievable (this is what
303+
// apply_conversation_config relies on).
304+
let stream_config = events.unwrap().config();
305+
assert!(stream_config.is_ok());
306+
307+
// ensure_active_conversation_stream should be a no-op.
308+
ws.ensure_active_conversation_stream(config).unwrap();
309+
assert!(ws.get_events(&id).is_some());
310+
}
311+
312+
/// Regression test for the bug where continuing a conversation without the
313+
/// original config passed in via `--cfg` caused a spurious `ConfigDelta` that
314+
/// reset the model and disabled all tools.
315+
///
316+
/// The root cause was that `load_partial_config` ran before conversation events
317+
/// were loaded from disk, so `apply_conversation_config` couldn't read the
318+
/// stream config and fell back to an empty partial.
319+
///
320+
/// This test exercises the full round-trip:
321+
/// 1. Create a conversation with a custom model name, persist to disk.
322+
/// 2. Reload with `load_conversation_index` only (no config needed).
323+
/// 3. Simulate `apply_conversation_config`: merge stream config into a bare
324+
/// partial (as if no custom config was passed).
325+
/// 4. Build the final config and assert the custom model name survived.
326+
/// 5. Assert that `get_config_delta_from_cli` would produce no delta.
327+
#[test]
328+
fn test_conversation_config_preserved_across_reload() {
329+
let tmp = tempdir().unwrap();
330+
let root = tmp.path().join("root");
331+
let storage_path = root.join("storage");
332+
333+
// Build a config with a distinctive model name.
334+
let mut custom_config = AppConfig::new_test();
335+
custom_config.assistant.model.id =
336+
ModelIdOrAliasConfig::Id((ProviderId::Anthropic, "custom-model").try_into().unwrap());
337+
let id = ConversationId::try_from(datetime!(2024-05-20 10:00:00 Z)).unwrap();
338+
339+
// Persist a conversation that was created with the custom config.
340+
{
341+
let mut ws = Workspace::new(&root).persisted_at(&storage_path).unwrap();
342+
ws.create_conversation_with_id(id, Conversation::default(), Arc::new(custom_config));
343+
ws.set_active_conversation_id(id, DateTime::<Utc>::UNIX_EPOCH)
344+
.unwrap();
345+
ws.persist().unwrap();
346+
}
347+
348+
// Simulate a second invocation WITHOUT the custom config.
349+
let mut ws = Workspace::new(&root).persisted_at(&storage_path).unwrap();
350+
ws.disable_persistence();
351+
ws.load_conversation_index().unwrap();
352+
353+
// Simulate `apply_conversation_config`: merge the stream's config into a
354+
// bare (default) partial, exactly as the CLI does when no `--cfg` flag is
355+
// provided. We use new_test().to_partial() to represent the default config
356+
// (file-based + env, but no custom config overlay).
357+
let bare_partial = AppConfig::new_test().to_partial();
358+
let stream_config = ws
359+
.get_events(&id)
360+
.expect("events must be accessible")
361+
.config()
362+
.expect("valid config")
363+
.to_partial();
364+
let merged = load_partial(bare_partial, stream_config).expect("merge ok");
365+
let final_config = build(merged).expect("valid config");
366+
367+
// The custom config's model name must survive the round-trip.
368+
let resolved = final_config.assistant.model.id.resolved();
369+
assert_eq!(
370+
resolved.name.as_ref(),
371+
"custom-model",
372+
"conversation config must be preserved when continuing without --cfg flag"
373+
);
374+
375+
// The delta must NOT contain a model change — this was the core symptom of
376+
// the bug, where the model reverted to the default.
377+
let stream_partial = ws.get_events(&id).unwrap().config().unwrap().to_partial();
378+
let delta = stream_partial.delta(final_config.to_partial());
379+
assert_eq!(
380+
delta.assistant.model.id,
381+
PartialModelIdOrAliasConfig::empty(),
382+
"config delta must not contain a model change when continuing a conversation without \
383+
overrides"
384+
);
385+
}
386+
260387
#[test]
261388
fn test_workspace_persist_active_conversation() {
262389
let tmp = tempdir().unwrap();

crates/jp_workspace/src/sanitize.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ impl Workspace {
5050
/// those that fail validation, and ensures the active conversation ID
5151
/// resolves to a valid conversation. Returns a report of what was repaired.
5252
///
53-
/// This should be called before [`load_conversations_from_disk`] to
53+
/// This should be called before [`load_conversation_index`] to
5454
/// guarantee the filesystem is in a consistent state.
5555
///
56-
/// [`load_conversations_from_disk`]: Self::load_conversations_from_disk
56+
/// [`load_conversation_index`]: Self::load_conversation_index
5757
pub fn sanitize(&mut self) -> Result<SanitizeReport> {
5858
let storage = self.storage.as_ref().ok_or(Error::MissingStorage)?;
5959

crates/jp_workspace/src/sanitize_tests.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ fn test_reassigns_active_when_trashed() {
7979
assert!(report.active_reassigned);
8080
assert!(!report.default_created);
8181

82-
ws.load_conversations_from_disk(test_config()).unwrap();
82+
ws.load_conversation_index().unwrap();
8383
assert_eq!(ws.active_conversation_id(), id2);
8484
}
8585

@@ -100,7 +100,7 @@ fn test_reassigns_to_most_recent_valid() {
100100
let report = ws.sanitize().unwrap();
101101

102102
assert!(report.active_reassigned);
103-
ws.load_conversations_from_disk(test_config()).unwrap();
103+
ws.load_conversation_index().unwrap();
104104
assert_eq!(ws.active_conversation_id(), id2);
105105
}
106106

@@ -123,7 +123,7 @@ fn test_reassigns_when_active_has_no_directory() {
123123
assert!(report.active_reassigned);
124124
assert!(!report.default_created);
125125

126-
ws.load_conversations_from_disk(test_config()).unwrap();
126+
ws.load_conversation_index().unwrap();
127127
assert_eq!(ws.active_conversation_id(), id2);
128128
}
129129

@@ -143,7 +143,8 @@ fn test_default_created_when_all_trashed() {
143143
assert!(report.default_created);
144144
assert_eq!(report.trashed.len(), 1);
145145

146-
ws.load_conversations_from_disk(test_config()).unwrap();
146+
ws.load_conversation_index().unwrap();
147+
ws.ensure_active_conversation_stream(test_config()).unwrap();
147148
}
148149

149150
#[test]
@@ -162,7 +163,7 @@ fn test_corrupt_global_metadata_reassigns_to_valid() {
162163
assert!(report.active_reassigned);
163164
assert!(!report.default_created);
164165

165-
ws.load_conversations_from_disk(test_config()).unwrap();
166+
ws.load_conversation_index().unwrap();
166167
assert_eq!(ws.active_conversation_id(), id2);
167168
}
168169

@@ -178,7 +179,8 @@ fn test_corrupt_global_metadata_with_no_conversations() {
178179
assert!(!report.has_repairs());
179180
assert!(!storage.conversations_metadata_exists());
180181

181-
ws.load_conversations_from_disk(test_config()).unwrap();
182+
ws.load_conversation_index().unwrap();
183+
ws.ensure_active_conversation_stream(test_config()).unwrap();
182184
}
183185

184186
#[test]
@@ -235,7 +237,7 @@ fn test_sanitize_then_load_with_mixed_valid_and_invalid() {
235237
assert!(report.active_reassigned);
236238
assert!(!report.default_created);
237239

238-
ws.load_conversations_from_disk(test_config()).unwrap();
240+
ws.load_conversation_index().unwrap();
239241
assert_eq!(ws.active_conversation_id(), id3);
240242
assert!(ws.get_conversation(&id1).is_some());
241243
}

0 commit comments

Comments
 (0)