Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions docs/architecture-audit-2026-09-23/ModelSelectionImplementation.md

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions docs/frontend-ui-audit-2026-09-23/ModelCatalogProjection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Model catalog projection UI audit

The change updates account/catalog derivation only. Existing two-column navigation, search, source scopes, selection controls, dimensions, and styling are retained. No new UI primitive or action control is introduced.

| Line | Element | Verdict | Reason | Suggested change |
| --- | --- | --- | --- | --- |
| `src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/VariantPill.tsx:192` | Compound effort/thinking/speed trigger | keep with reason | Existing shared `Button` retains its ref, accessible name, disclosure state, and dropdown lifecycle. Custom layout accommodates the independently present brain, effort, speed, separators, and pencil within the established compact pill geometry. Only variant metadata input changes. | None. |
| `src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/keyFirstItems.tsx:88` | Account/model row labels and family counts | keep with reason | Existing Spotlight row renderer owns interaction and focus; these spans provide label/count content and introduce no independent click target. Existing compact sizes remain unchanged as requested. | None. |
| `src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/modelSelectionItems.tsx:90` | Current/recent model label and source trail | keep with reason | Existing icon, truncation, semantic text colors, and Spotlight action are retained. Catalog metadata replaces model-ID inference without changing the rendered control family. | None. |
| `src/scaffold/GlobalSpotlight/palettes/UnifiedModelPalette/sourceItems.tsx:172` | Source selection row | keep with reason | Existing Spotlight action and `VariantPill` remain the only interactive controls. The fix makes source eligibility agree with model eligibility, including variant-only catalogs. | None. |
| `src/modules/MainApp/AgentOrgs/config/shared/ModelPicker.tsx:83` | Agent Org model selector | keep with reason | Continues using shared searchable `Select` with the same clear-selection option, size, callback, and disabled state; only compatible account projection is shared. | None. |
| `src/engines/ChatPanel/InputArea/components/ModelPill.tsx:250` | Session model selector | keep with reason | Existing selector pills, popover/dropdown entrypoints and visual markup are retained. Async completion now owns default/recent publication; the menu still dismisses immediately and session identity stays optimistic. | None. |

Verdict totals: **0 fix**, **6 keep with reason**, **0 abstract**.

Verification: TypeScript AST inspection of all 34 changed production TS/TSX files (including the six TSX files above) found zero raw button/form-control elements, native button creation calls, or clickable `div`/`span` substitutes. Focused behavior tests cover model/source/key-first catalog parity, independent thinking/effort/speed dimensions, and existing palette flows. Rendered ModelPill tests cover successful save, rejection/retry, direct Member ownership and late completion after navigation. No real Tauri screenshots were captured; visual parity is based on unchanged markup/classes plus rendered tests, not a live-account visual claim.
34 changes: 26 additions & 8 deletions src-tauri/crates/agent-core/src/core/session/gateway_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::foundation::persistence::images::load_image_as_data_url;

use crate::session::persistence as unified_persistence;
use crate::session::IdeContext;
use crate::state::{AgentAppState, AgentSession};
use crate::state::{AgentAppState, AgentSession, SessionRuntime};
use core_types::key_source::KeySource;

/// Process a single inbound gateway message.
Expand All @@ -23,6 +23,22 @@ pub async fn process_gateway_message(
session: Arc<AgentSession>,
ide_context: Option<&IdeContext>,
app_handle: Option<tauri::AppHandle>,
) -> Result<Option<OutboundMessage>, String> {
let runtime = session
.get_runtime()
.await
.ok_or_else(|| format!("Session {} runtime not initialized", session.id))?;
process_gateway_message_with_runtime(msg, session, runtime, ide_context, app_handle).await
}

/// Keep the dispatcher-captured runtime through gateway preprocessing and the
/// provider call, even when a newer selection clears the session cache.
pub(crate) async fn process_gateway_message_with_runtime(
msg: InboundMessage,
session: Arc<AgentSession>,
runtime: Arc<SessionRuntime>,
ide_context: Option<&IdeContext>,
app_handle: Option<tauri::AppHandle>,
) -> Result<Option<OutboundMessage>, String> {
let preview: String = crate::utils::safe_truncate_chars_to_string(&msg.content, 80);
info!(
Expand All @@ -32,11 +48,6 @@ pub async fn process_gateway_message(

let session_key = msg.session_key();

let runtime = session
.get_runtime()
.await
.ok_or_else(|| format!("Session {} runtime not initialized", session.id))?;

let effective_model = runtime.model.clone();

// Ensure a session record exists in the unified `agent_sessions` table.
Expand All @@ -47,6 +58,7 @@ pub async fn process_gateway_message(
let user_input_preview: String =
crate::utils::safe_truncate_chars_to_string(&msg.content, 200);
let model = effective_model.clone();
let account_id = runtime.account_id.clone();
if let Err(err) =
tokio::task::spawn_blocking(move || match unified_persistence::get_session(&sk) {
Ok(Some(_)) => Ok(()),
Expand All @@ -58,6 +70,7 @@ pub async fn process_gateway_message(
name: format!("Channel: {}", channel),
status: super::SessionStatus::Running.as_str().to_string(),
model: Some(model),
account_id,
session_type: session_type.to_string(),
channel: Some(channel),
chat_id: Some(chat_id),
Expand Down Expand Up @@ -151,8 +164,13 @@ pub async fn process_gateway_message(
turn_intent_id: uuid::Uuid::new_v4().to_string(),
};

let result =
crate::session::process_message(Arc::clone(&session), input, app_handle.clone()).await;
let result = super::turn::entry::process_message_with_runtime(
Arc::clone(&session),
runtime,
input,
app_handle.clone(),
)
.await;

// Compact-fork redirect.
if let Ok(ref pr) = result {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pub(super) async fn handle_background_launch_failure(
}

match mark_session_failed(session_id.to_string()).await {
Ok(()) => crate::lifecycle::emit_session_status_changed(
Ok(_terminal_at) => crate::lifecycle::emit_session_status_changed(
app_handle,
session_id,
crate::persistence::db_helpers::AgentSessionStatus::Failed,
Expand Down Expand Up @@ -249,18 +249,21 @@ pub(super) fn broadcast_launch_send_error(session_id: &str, message: &str) {
broadcast_agent_error_structured(session_id, &error);
}

pub(super) async fn mark_session_failed(session_id: String) -> Result<(), String> {
pub(super) async fn mark_session_failed(session_id: String) -> Result<String, String> {
tokio::task::spawn_blocking(move || {
let Some(mut record) =
crate::session::persistence::get_session(&session_id).map_err(|err| err.to_string())?
else {
let terminal_at = chrono::Utc::now().to_rfc3339();
let changed = crate::session::persistence::update_status_at(
&session_id,
crate::session::SessionStatus::Failed,
&terminal_at,
)
.map_err(|err| err.to_string())?;
if !changed {
return Err(format!(
"session {session_id} disappeared before first-turn failure could be persisted"
));
};
record.status = crate::session::SessionStatus::Failed.as_str().to_string();
record.updated_at = chrono::Utc::now().to_rfc3339();
crate::session::persistence::upsert_session(&record).map_err(|err| err.to_string())
}
Ok(terminal_at)
})
.await
.map_err(|err| err.to_string())?
Expand Down
30 changes: 25 additions & 5 deletions src-tauri/crates/agent-core/src/core/session/launch/launch_org.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,8 +327,11 @@ pub(super) async fn send_initial_turn(
content,
None,
crate::state::commands::session::identity::IdentityOverrides {
model,
account_id,
// The launch row already owns the chosen pair. Send resolves
// it under its own identity lock so a newer picker change
// cannot be replaced by this task's captured launch defaults.
model: None,
account_id: None,
workspace_root: Some(workspace_root),
native_harness_type,
},
Expand All @@ -350,8 +353,20 @@ pub(super) async fn send_initial_turn(
return Ok(());
}

let identity_guard = crate::state::session_identity_lock(session_id)
.await
.lock_owned()
.await;
let (model, account_id) =
crate::state::commands::session::identity::resolve_initialization_model_pair(
state,
session_id,
model.as_deref(),
account_id.as_deref(),
)
.await?;
let model = model.ok_or_else(|| "model is required for sub-agent launch".to_string())?;
let launch_spec = crate::init::launch_spec::AgentLaunchSpec::work_item_session(
let mut launch_spec = crate::init::launch_spec::AgentLaunchSpec::work_item_session(
state,
session_id,
&model,
Expand All @@ -361,16 +376,21 @@ pub(super) async fn send_initial_turn(
&sub_agent_ids,
)
.await?;
// Preserve credential-owned None rather than the constructor's empty
// account placeholder, which would conflict with a Market selector.
launch_spec.account_id = account_id;
crate::init::init_session(state, launch_spec).await?;
// `send_message_impl` acquires this same non-reentrant lock itself.
drop(identity_guard);

crate::state::commands::session::message::send_message_impl(
state,
session_id.to_string(),
content,
None,
crate::state::commands::session::identity::IdentityOverrides {
model: Some(model),
account_id,
model: None,
account_id: None,
workspace_root: Some(workspace_root),
native_harness_type,
},
Expand Down
25 changes: 25 additions & 0 deletions src-tauri/crates/agent-core/src/core/session/launch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,28 @@ async fn generate_title_before_first_turn(
return;
}

// This task is spawned independently of the initial turn. Its captured
// launch defaults may be older than an already acknowledged picker edit.
let identity_guard = crate::state::session_identity_lock(session_id)
.await
.lock_owned()
.await;
let (model, account_id) =
match crate::state::commands::session::identity::resolve_initialization_model_pair(
state,
session_id,
model.as_deref(),
account_id.as_deref(),
)
.await
{
Ok(pair) => pair,
Err(error) => {
tracing::warn!(session_id = %session_id, %error, "[session_title] failed to load current model selection");
return;
}
};

let launch_spec = match AgentLaunchSpec::from_session_sources(
state,
session_id,
Expand Down Expand Up @@ -287,6 +309,9 @@ async fn generate_title_before_first_turn(
return;
}
};
// Only runtime installation is serialized; title generation is an
// independent provider request and must not block a next-turn selection.
drop(identity_guard);

let title = crate::session::title::generate_and_persist_session_title(
session_id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub use ops::{
upsert_session,
};
pub(crate) use ops::{
delete_session_with_connection, finish_session_delete, prepare_session_delete,
delete_session_with_connection, finish_session_delete, prepare_session_delete, update_status_at,
};
pub(super) use record::{row_to_record, UNIFIED_SESSION_SELECT};
pub use record::{session_type, UnifiedSessionRecord};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,12 +390,21 @@ fn settles_linked_session(status: SessionStatus) -> bool {

/// Update session status.
pub fn update_status(session_id: &str, status: SessionStatus) -> SqliteResult<bool> {
update_status_at(session_id, status, &Utc::now().to_rfc3339())
}

/// Status-only write with the caller's event timestamp. This must never carry
/// a previously loaded model/account pair back into the authoritative row.
pub(crate) fn update_status_at(
session_id: &str,
status: SessionStatus,
updated_at: &str,
) -> SqliteResult<bool> {
let changed = with_sessions_writer(|| -> SqliteResult<bool> {
let conn = get_connection()?;
let now = Utc::now().to_rfc3339();
let updated = conn.execute(
"UPDATE agent_sessions SET status = ?2, updated_at = ?3 WHERE session_id = ?1",
params![session_id, status.as_str(), now],
params![session_id, status.as_str(), updated_at],
)?;
Ok(updated > 0)
})?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,22 @@ fn seed_session(session_id: &str, status: SessionStatus) {
upsert_session(&record).expect("seed upsert");
}

#[test]
fn terminal_status_write_preserves_a_newer_model_selection() {
let _sandbox = test_env::sandbox();
let sid = "status-after-identity-change";
seed_session(sid, SessionStatus::Running);
super::ops::update_model_and_account(sid, "new-model", Some("new-account")).unwrap();
let terminal_at = "2026-09-23T10:00:00Z";
assert!(super::ops::update_status_at(sid, SessionStatus::Failed, terminal_at).unwrap());
let record = super::ops::get_session(sid).unwrap().unwrap();
assert_eq!(record.model.as_deref(), Some("new-model"));
assert_eq!(record.account_id.as_deref(), Some("new-account"));
assert_eq!(record.status, SessionStatus::Failed.as_str());
assert_eq!(record.updated_at, terminal_at);
assert!(!super::ops::update_status_at("missing", SessionStatus::Failed, terminal_at).unwrap());
}

#[test]
#[serial_test::serial]
fn delete_session_refuses_active_shell_before_removing_session_row() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub use crud::{
update_worktree_merge_status, upsert_session, UnifiedSessionRecord,
};
pub(crate) use crud::{
delete_session_with_connection, finish_session_delete, prepare_session_delete,
delete_session_with_connection, finish_session_delete, prepare_session_delete, update_status_at,
};
pub use sidebar::{
list_agent_org_root_sessions_page, list_standalone_coding_sessions_page,
Expand Down
78 changes: 74 additions & 4 deletions src-tauri/crates/agent-core/src/core/session/project_init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ use crate::session::persistence as unified_persistence;
use crate::state::{AgentAppState, SessionRuntime};
use core_types::key_source::KeySource;

fn seed_missing_workspace_identity(
record: &mut unified_persistence::UnifiedSessionRecord,
model: String,
account_id: Option<String>,
) {
if record.model.as_deref().is_none_or(str::is_empty) {
record.model = Some(model);
record.account_id = account_id;
}
}

/// Initialize a workspace-scoped session's runtime using the unified init path.
///
/// Agent resolve contract (design doc §11.4): coding sessions resolve against the session's
Expand All @@ -21,6 +32,10 @@ use core_types::key_source::KeySource;
/// `message_pipeline` fallback branch (which can only synthesize a
/// generic OS-typed row with an empty `workspace_path`) ever sees this
/// session id.
///
/// The production channel dispatcher owns `session_identity_lock` across
/// resolving the current model/account, this initialization, and the eager
/// persistence below. Do not reacquire that non-reentrant lock here.
pub async fn init_workspace_session(
state: &AgentAppState,
session_id: &str,
Expand Down Expand Up @@ -59,10 +74,9 @@ pub async fn init_workspace_session(
// don't overwrite channel / chat_id / parent metadata that a
// previous dispatch established.
Some(mut existing) => {
existing.model = Some(model_owned);
if existing.account_id.is_none() {
existing.account_id = account_owned;
}
// The caller resolved the chosen pair while holding the
// identity lock. Preserve an existing persisted selection.
seed_missing_workspace_identity(&mut existing, model_owned, account_owned);
let needs_workspace = existing
.workspace_path
.as_deref()
Expand Down Expand Up @@ -133,3 +147,59 @@ pub async fn init_workspace_session(

Ok(runtime)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn eager_workspace_write_preserves_selected_model_and_account() {
let mut record = unified_persistence::UnifiedSessionRecord {
model: Some("selected".into()),
account_id: Some("selected-account".into()),
..Default::default()
};
seed_missing_workspace_identity(
&mut record,
"stale-default".into(),
Some("stale-account".into()),
);
assert_eq!(record.model.as_deref(), Some("selected"));
assert_eq!(record.account_id.as_deref(), Some("selected-account"));
}

#[test]
fn eager_workspace_write_does_not_fill_a_credential_owned_account() {
let mut record = unified_persistence::UnifiedSessionRecord {
model: Some("market-model".into()),
credential_source: Some("market:selection".into()),
..Default::default()
};
seed_missing_workspace_identity(
&mut record,
"gateway-model".into(),
Some("personal-account".into()),
);
assert_eq!(record.model.as_deref(), Some("market-model"));
assert_eq!(record.account_id, None);
assert_eq!(
record.credential_source.as_deref(),
Some("market:selection")
);
}

#[test]
fn eager_workspace_write_seeds_identity_as_a_complete_pair() {
let mut record = unified_persistence::UnifiedSessionRecord {
account_id: Some("identity-less-old-account".into()),
..Default::default()
};
seed_missing_workspace_identity(
&mut record,
"gateway-model".into(),
Some("gateway-account".into()),
);
assert_eq!(record.model.as_deref(), Some("gateway-model"));
assert_eq!(record.account_id.as_deref(), Some("gateway-account"));
}
}
Loading
Loading