Skip to content

Commit b9bcad9

Browse files
authored
fix(acp): persist runtime model and mode into assistant preferences (#482)
## Summary - Mirror runtime model/mode switches from `set_config_option` into the persisted assistant snapshot and `assistant_preferences` so an assistant configured with `default_*_mode = auto` actually remembers the user's most recent pick across conversations. - Skip the write-back unless the agent confirms the change as `Observed` — `CommandAck` only acknowledges the request, and unrelated option ids (e.g. `thought_level`) have no preference mapping. - Persistence failures are logged via `warn!` but do not roll back the user-facing config switch. ## Why Before this change, switching the model inside an ACP conversation reached the running agent through `acpConversation.setConfigOption`, but that code path forwarded straight to `IAgentTask::set_config_option` and never touched `assistant_preferences.last_*`. The next conversation seeded from the same assistant in `auto` mode therefore re-used the previously persisted value, ignoring the user's most recent pick. The aionrs path already wrote back via `ConversationService::update`; this brings the ACP path to parity. ## Verification - `cargo test -p aionui-conversation` (270 lib + integration tests pass, including 3 new `set_config_option_*` cases) - `cargo clippy -p aionui-conversation --tests --no-deps -- -D warnings` - `cargo fmt -p aionui-conversation --check` Co-authored-by: zk <>
1 parent 77d252f commit b9bcad9

2 files changed

Lines changed: 276 additions & 5 deletions

File tree

crates/aionui-conversation/src/service_ops.rs

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
use std::path::Component;
1111

1212
use aionui_api_types::{
13-
GetConfigOptionsResponse, SetConfigOptionRequest, SetConfigOptionResponse, SideQuestionRequest,
14-
SideQuestionResponse, SlashCommandItem, WorkspaceBrowseQuery, WorkspaceEntry,
13+
ConfigOptionConfirmation, GetConfigOptionsResponse, SetConfigOptionRequest, SetConfigOptionResponse,
14+
SideQuestionRequest, SideQuestionResponse, SlashCommandItem, WorkspaceBrowseQuery, WorkspaceEntry,
1515
};
16+
use aionui_common::ErrorChain;
17+
use tracing::warn;
1618

1719
use crate::ConversationError;
18-
use crate::service::ConversationService;
20+
use crate::service::{AssistantRuntimePreferenceUpdate, ConversationService};
1921

2022
const MAX_DIR_DEPTH: usize = 10;
2123

@@ -48,10 +50,56 @@ impl ConversationService {
4850
reason: "value must not be empty".into(),
4951
});
5052
}
51-
self.task(conversation_id)?
53+
let response = self
54+
.task(conversation_id)?
5255
.set_config_option(option_id, &req.value)
5356
.await
54-
.map_err(ConversationError::from)
57+
.map_err(ConversationError::from)?;
58+
59+
// Mirror runtime model/mode switches into the persisted assistant
60+
// snapshot + preference so the next conversation seeded from this
61+
// assistant in `auto` mode reflects the latest pick. We only act on
62+
// observed confirmations — `command_ack` means the agent merely
63+
// accepted the request, not that the value is in effect, and
64+
// unrelated option ids (e.g. `thought_level`) have no preference
65+
// mapping. Persistence failures are logged but do not roll back the
66+
// user-facing config switch.
67+
if response.confirmation == ConfigOptionConfirmation::Observed {
68+
let updates = match option_id {
69+
"model" => Some(AssistantRuntimePreferenceUpdate {
70+
model: Some(req.value.as_str()),
71+
permission: None,
72+
}),
73+
"mode" => Some(AssistantRuntimePreferenceUpdate {
74+
model: None,
75+
permission: Some(req.value.as_str()),
76+
}),
77+
_ => None,
78+
};
79+
if let Some(updates) = updates {
80+
if let Err(err) = self.persist_runtime_assistant_snapshot(conversation_id, updates).await {
81+
warn!(
82+
conversation_id,
83+
option_id,
84+
error = %ErrorChain(&err),
85+
"Failed to persist runtime assistant snapshot after set_config_option",
86+
);
87+
}
88+
if let Err(err) = self
89+
.persist_runtime_assistant_preferences(conversation_id, updates)
90+
.await
91+
{
92+
warn!(
93+
conversation_id,
94+
option_id,
95+
error = %ErrorChain(&err),
96+
"Failed to persist runtime assistant preferences after set_config_option",
97+
);
98+
}
99+
}
100+
}
101+
102+
Ok(response)
55103
}
56104

57105
// ── Usage / Slash commands ──────────────────────────────────────

crates/aionui-conversation/src/service_test.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2505,6 +2505,229 @@ async fn command_ack_does_not_persist_assistant_preference_in_core_service() {
25052505
assert!(refreshed.extra.get("current_model_id").is_none());
25062506
}
25072507

2508+
#[tokio::test]
2509+
async fn set_config_option_persists_runtime_model_into_assistant_preference_when_observed() {
2510+
let task_mgr = Arc::new(MockTaskManager::new());
2511+
let (svc, _broadcaster, repo, definition_repo, overlay_repo, preference_repo) =
2512+
make_service_with_mock_task_manager_and_assistant_support(task_mgr.clone()).await;
2513+
2514+
upsert_test_assistant_definition(
2515+
&definition_repo,
2516+
"asstdef_acp_auto",
2517+
"assistant-acp-auto",
2518+
"codex",
2519+
"auto",
2520+
"auto",
2521+
)
2522+
.await;
2523+
overlay_repo
2524+
.upsert(&UpsertAssistantOverlayParams {
2525+
definition_id: "asstdef_acp_auto",
2526+
enabled: true,
2527+
sort_order: 0,
2528+
agent_backend_override: None,
2529+
last_used_at: None,
2530+
})
2531+
.await
2532+
.unwrap();
2533+
preference_repo
2534+
.upsert(&UpsertAssistantPreferenceParams {
2535+
definition_id: "asstdef_acp_auto",
2536+
last_model_id: Some("legacy-acp-model"),
2537+
last_permission_value: Some("legacy-mode"),
2538+
last_skill_ids: "[]",
2539+
last_disabled_builtin_skill_ids: "[]",
2540+
last_mcp_ids: "[]",
2541+
})
2542+
.await
2543+
.unwrap();
2544+
2545+
let conv = create_assistant_backed_conversation(&svc, "user_1", "acp", "codex", "assistant-acp-auto").await;
2546+
2547+
let agent = Arc::new(MockAgent::new(&conv.id));
2548+
task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent));
2549+
2550+
let result = svc
2551+
.set_config_option(
2552+
&conv.id,
2553+
"model",
2554+
SetConfigOptionRequest {
2555+
value: "gpt-5.5".to_owned(),
2556+
},
2557+
)
2558+
.await
2559+
.unwrap();
2560+
assert_eq!(result.confirmation, ConfigOptionConfirmation::Observed);
2561+
2562+
let pref_after_model = preference_repo.get("asstdef_acp_auto").await.unwrap().unwrap();
2563+
assert_eq!(pref_after_model.last_model_id.as_deref(), Some("gpt-5.5"));
2564+
assert_eq!(pref_after_model.last_permission_value.as_deref(), Some("legacy-mode"));
2565+
let snapshot_after_model = repo.get_assistant_snapshot(&conv.id).await.unwrap().unwrap();
2566+
assert_eq!(snapshot_after_model.resolved_model_id.as_deref(), Some("gpt-5.5"));
2567+
2568+
svc.set_config_option(
2569+
&conv.id,
2570+
"mode",
2571+
SetConfigOptionRequest {
2572+
value: "plan".to_owned(),
2573+
},
2574+
)
2575+
.await
2576+
.unwrap();
2577+
let pref_after_mode = preference_repo.get("asstdef_acp_auto").await.unwrap().unwrap();
2578+
assert_eq!(pref_after_mode.last_model_id.as_deref(), Some("gpt-5.5"));
2579+
assert_eq!(pref_after_mode.last_permission_value.as_deref(), Some("plan"));
2580+
let snapshot_after_mode = repo.get_assistant_snapshot(&conv.id).await.unwrap().unwrap();
2581+
assert_eq!(snapshot_after_mode.resolved_permission_value.as_deref(), Some("plan"));
2582+
2583+
// Unrelated option ids must not touch preferences.
2584+
svc.set_config_option(
2585+
&conv.id,
2586+
"thought_level",
2587+
SetConfigOptionRequest {
2588+
value: "high".to_owned(),
2589+
},
2590+
)
2591+
.await
2592+
.unwrap();
2593+
let pref_after_thought = preference_repo.get("asstdef_acp_auto").await.unwrap().unwrap();
2594+
assert_eq!(pref_after_thought.last_model_id.as_deref(), Some("gpt-5.5"));
2595+
assert_eq!(pref_after_thought.last_permission_value.as_deref(), Some("plan"));
2596+
}
2597+
2598+
#[tokio::test]
2599+
async fn set_config_option_skips_preference_write_back_when_default_mode_is_fixed() {
2600+
let task_mgr = Arc::new(MockTaskManager::new());
2601+
let (svc, _broadcaster, repo, definition_repo, overlay_repo, preference_repo) =
2602+
make_service_with_mock_task_manager_and_assistant_support(task_mgr.clone()).await;
2603+
2604+
upsert_test_assistant_definition(
2605+
&definition_repo,
2606+
"asstdef_acp_fixed",
2607+
"assistant-acp-fixed",
2608+
"codex",
2609+
"fixed",
2610+
"fixed",
2611+
)
2612+
.await;
2613+
overlay_repo
2614+
.upsert(&UpsertAssistantOverlayParams {
2615+
definition_id: "asstdef_acp_fixed",
2616+
enabled: true,
2617+
sort_order: 0,
2618+
agent_backend_override: None,
2619+
last_used_at: None,
2620+
})
2621+
.await
2622+
.unwrap();
2623+
preference_repo
2624+
.upsert(&UpsertAssistantPreferenceParams {
2625+
definition_id: "asstdef_acp_fixed",
2626+
last_model_id: Some("legacy-fixed-model"),
2627+
last_permission_value: Some("legacy-fixed-mode"),
2628+
last_skill_ids: "[]",
2629+
last_disabled_builtin_skill_ids: "[]",
2630+
last_mcp_ids: "[]",
2631+
})
2632+
.await
2633+
.unwrap();
2634+
2635+
let conv = create_assistant_backed_conversation(&svc, "user_1", "acp", "codex", "assistant-acp-fixed").await;
2636+
let agent = Arc::new(MockAgent::new(&conv.id));
2637+
task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent));
2638+
2639+
svc.set_config_option(
2640+
&conv.id,
2641+
"model",
2642+
SetConfigOptionRequest {
2643+
value: "transient-model".to_owned(),
2644+
},
2645+
)
2646+
.await
2647+
.unwrap();
2648+
svc.set_config_option(
2649+
&conv.id,
2650+
"mode",
2651+
SetConfigOptionRequest {
2652+
value: "transient-mode".to_owned(),
2653+
},
2654+
)
2655+
.await
2656+
.unwrap();
2657+
2658+
let pref = preference_repo.get("asstdef_acp_fixed").await.unwrap().unwrap();
2659+
assert_eq!(pref.last_model_id.as_deref(), Some("legacy-fixed-model"));
2660+
assert_eq!(pref.last_permission_value.as_deref(), Some("legacy-fixed-mode"));
2661+
// The snapshot still tracks the runtime override so the active session reflects it,
2662+
// even though the persisted assistant preference must not change for fixed defaults.
2663+
let snapshot = repo.get_assistant_snapshot(&conv.id).await.unwrap().unwrap();
2664+
assert_eq!(snapshot.resolved_model_id.as_deref(), Some("transient-model"));
2665+
assert_eq!(snapshot.resolved_permission_value.as_deref(), Some("transient-mode"));
2666+
}
2667+
2668+
#[tokio::test]
2669+
async fn set_config_option_command_ack_does_not_persist_assistant_preference() {
2670+
let task_mgr = Arc::new(MockTaskManager::new());
2671+
let (svc, _broadcaster, repo, definition_repo, overlay_repo, preference_repo) =
2672+
make_service_with_mock_task_manager_and_assistant_support(task_mgr.clone()).await;
2673+
2674+
upsert_test_assistant_definition(
2675+
&definition_repo,
2676+
"asstdef_acp_ack",
2677+
"assistant-acp-ack",
2678+
"codex",
2679+
"auto",
2680+
"auto",
2681+
)
2682+
.await;
2683+
overlay_repo
2684+
.upsert(&UpsertAssistantOverlayParams {
2685+
definition_id: "asstdef_acp_ack",
2686+
enabled: true,
2687+
sort_order: 0,
2688+
agent_backend_override: None,
2689+
last_used_at: None,
2690+
})
2691+
.await
2692+
.unwrap();
2693+
preference_repo
2694+
.upsert(&UpsertAssistantPreferenceParams {
2695+
definition_id: "asstdef_acp_ack",
2696+
last_model_id: Some("legacy-ack-model"),
2697+
last_permission_value: Some("legacy-ack-mode"),
2698+
last_skill_ids: "[]",
2699+
last_disabled_builtin_skill_ids: "[]",
2700+
last_mcp_ids: "[]",
2701+
})
2702+
.await
2703+
.unwrap();
2704+
2705+
let conv = create_assistant_backed_conversation(&svc, "user_1", "acp", "codex", "assistant-acp-ack").await;
2706+
let agent = Arc::new(
2707+
MockAgent::new(&conv.id).with_set_config_option_response(SetConfigOptionResponse {
2708+
confirmation: ConfigOptionConfirmation::CommandAck,
2709+
config_options: None,
2710+
}),
2711+
);
2712+
task_mgr.insert_agent(&conv.id, AgentInstance::Mock(agent));
2713+
2714+
svc.set_config_option(
2715+
&conv.id,
2716+
"model",
2717+
SetConfigOptionRequest {
2718+
value: "ack-only-model".to_owned(),
2719+
},
2720+
)
2721+
.await
2722+
.unwrap();
2723+
2724+
let pref = preference_repo.get("asstdef_acp_ack").await.unwrap().unwrap();
2725+
assert_eq!(pref.last_model_id.as_deref(), Some("legacy-ack-model"));
2726+
assert_eq!(pref.last_permission_value.as_deref(), Some("legacy-ack-mode"));
2727+
let snapshot = repo.get_assistant_snapshot(&conv.id).await.unwrap().unwrap();
2728+
assert_eq!(snapshot.resolved_model_id.as_deref(), Some("legacy-ack-model"));
2729+
}
2730+
25082731
#[tokio::test]
25092732
async fn update_aionrs_model_updates_assistant_preference_only_when_snapshot_model_mode_is_auto() {
25102733
let task_mgr = Arc::new(MockTaskManager::new());

0 commit comments

Comments
 (0)