@@ -2,6 +2,12 @@ use std::{collections::HashMap, fs, time::Duration};
22
33use camino_tempfile:: tempdir;
44use datetime_literal:: datetime;
5+ use jp_config:: {
6+ PartialConfig as _,
7+ fs:: load_partial,
8+ model:: id:: { ModelIdOrAliasConfig , PartialModelIdOrAliasConfig , ProviderId } ,
9+ util:: build,
10+ } ;
511use jp_conversation:: ConversationsMetadata ;
612use 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]
261388fn test_workspace_persist_active_conversation ( ) {
262389 let tmp = tempdir ( ) . unwrap ( ) ;
0 commit comments