Skip to content
Merged
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
96 changes: 81 additions & 15 deletions server/src/agents/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,25 @@ pub(crate) fn apply_session_state_update(
&current_with_identity,
params.get("title"),
params.get("titleSource"),
read_text(params, "agentSessionId").as_deref(),
&identity,
)? {
title = candidate.title;
runtime_settings.insert("titleSource".to_string(), json!(candidate.title_source));
/*
CDXC:SessionTitles 2026-09-17 DECISION:
User: save donated titles with a non-user source. A candidate that only
matched by store path (never by conversation id) must not land as
"user", because a user-sourced title outranks terminal-auto and the
agent's own later names could never replace it. Weak matches are
recorded as terminal-auto, so the conversation's real title replaces
them; id-confirmed candidates keep the donor's source.
*/
let title_source = if candidate.title_source == "user" && !candidate.same_conversation {
"terminal-auto".to_string()
} else {
candidate.title_source
};
runtime_settings.insert("titleSource".to_string(), json!(title_source));
reason = candidate.reason;
} else if next_agent.is_some() {
/*
Expand Down Expand Up @@ -840,6 +855,11 @@ pub(crate) struct TrustedTitleCandidate {
title: String,
title_source: String,
updated_at: Option<String>,
/// True when the candidate provably belongs to this session's own
/// conversation (matched by agent session id, or the event carried it).
/// Path-only matches are NOT proof for agents whose store is one shared
/// file, so their titles must never pin as user-sourced.
same_conversation: bool,
}

/// `project_sessions` is only hydrated once the event itself carried no trusted
Expand All @@ -850,11 +870,18 @@ pub(crate) fn select_trusted_title_for_identity(
current_session: &Value,
event_title: Option<&Value>,
event_title_source: Option<&Value>,
event_agent_session_id: Option<&str>,
identity: &ResolvedIdentity,
) -> Result<Option<TrustedTitleCandidate>, DomainStateError> {
if let Some(candidate) =
create_trusted_title_candidate(event_title, event_title_source, "event-title", None)
{
let same_conversation = event_agent_session_id.is_some()
&& event_agent_session_id == identity.agent_session_id.as_deref();
if let Some(candidate) = create_trusted_title_candidate(
event_title,
event_title_source,
"event-title",
None,
same_conversation,
) {
return Ok(Some(candidate));
}

Expand All @@ -864,7 +891,8 @@ pub(crate) fn select_trusted_title_for_identity(
`live_process_identity_update_is_noop` deliberately re-runs this pass on
every poll while the title is still a placeholder, so this hunt must not
hydrate the whole project. The rows are narrowed in SQL to the ones that
share the agent session id or path; `identities_match` still decides.
share the agent session id or path; `identities_match_strength` still
decides, including the shared-store carve-out.
*/
let identity_sessions = project_sessions.matching_identity(identity)?;
let live_candidate = select_newest_candidate(
Expand All @@ -879,9 +907,10 @@ pub(crate) fn select_trusted_title_for_identity(
agent_session_id: read_text_from_map(&runtime_settings, "agentSessionId"),
agent_session_path: read_text_from_map(&runtime_settings, "agentSessionPath"),
};
if !identities_match(identity, &candidate_identity) {
let Some(match_strength) = identities_match_strength(identity, &candidate_identity)
else {
return None;
}
};
let title = trusted_resume_title(session)?;
Some(TrustedTitleCandidate {
reason: format!(
Expand All @@ -897,6 +926,7 @@ pub(crate) fn select_trusted_title_for_identity(
updated_at: read_text_value(session, "lastActiveAt")
.or_else(|| read_text_value(session, "updatedAt")),
title,
same_conversation: match_strength == IdentityMatchStrength::ConversationId,
})
})
.collect(),
Expand Down Expand Up @@ -951,9 +981,10 @@ pub(crate) fn create_history_title_candidate(
hidden_record.and_then(|item| read_text_from_record(item, "agentSessionPath"))
}),
};
if !identities_match(identity, &candidate_identity) {
let Some(match_strength) = identities_match_strength(identity, &candidate_identity) else {
return None;
}
};
let same_conversation = match_strength == IdentityMatchStrength::ConversationId;

let updated_at = read_text_from_record(record, "lastInteractionAt")
.or_else(|| read_text_from_record(record, "closedAt"));
Expand All @@ -963,6 +994,7 @@ pub(crate) fn create_history_title_candidate(
session_record.get("titleSource"),
"previous-session-record-title",
updated_at.clone(),
same_conversation,
) {
return Some(candidate);
}
Expand All @@ -980,13 +1012,15 @@ pub(crate) fn create_history_title_candidate(
})),
"previous-session-primary-title",
updated_at.clone(),
same_conversation,
)
.or_else(|| {
create_trusted_title_candidate(
record.get("terminalTitle"),
Some(&json!("terminal-auto")),
"previous-session-terminal-title",
updated_at,
same_conversation,
)
})
}
Expand All @@ -996,6 +1030,7 @@ pub(crate) fn create_trusted_title_candidate(
title_source: Option<&Value>,
reason: &str,
updated_at: Option<String>,
same_conversation: bool,
) -> Option<TrustedTitleCandidate> {
let normalized_title = get_visible_terminal_title(title?.as_str()?)?
.trim()
Expand All @@ -1013,6 +1048,7 @@ pub(crate) fn create_trusted_title_candidate(
title: normalized_title,
title_source: normalized_source,
updated_at,
same_conversation,
})
}

Expand Down Expand Up @@ -1051,21 +1087,48 @@ pub(crate) fn read_text_from_record(record: &Map<String, Value>, key: &str) -> O
.map(str::to_string)
}

pub(crate) fn identities_match(left: &ResolvedIdentity, right: &ResolvedIdentity) -> bool {
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum IdentityMatchStrength {
/// Both sides carry the same agent session id: provably one conversation.
ConversationId,
/// Only the session store path matched. For per-conversation transcript
/// files (Claude, Codex) that still means the same conversation; for
/// shared-store agents it means nothing.
StorePath,
}

/// CDXC:SessionTitles 2026-09-17 DECISION:
/// User: for zcode, only accept a trusted-title sibling match when the conversation id matches too, and save donated titles with a non-user source.
/// zcode keeps every conversation in ONE SQLite database, so agentSessionPath is identical for all zcode sessions; the old path-only fallback let a fresh unbound pane adopt a sibling's user-pinned title ("111") and record it as user, which permanently outranked zcode's own titles.
/// Hermes stores its conversations in one state.db too, but the identity path carries its per-conversation mirror file (`hermes-chat-mirror/<id>.jsonl`), so its path fallback stays safe; add an agent here if its identity path ever becomes a shared multi-conversation store.
fn identity_uses_shared_session_store(agent: Option<&str>) -> bool {
normalize_agent_id(agent).as_deref() == Some("zcode")
}

pub(crate) fn identities_match_strength(
left: &ResolvedIdentity,
right: &ResolvedIdentity,
) -> Option<IdentityMatchStrength> {
let left_agent = normalize_agent_id(left.agent_id.as_deref());
let right_agent = normalize_agent_id(right.agent_id.as_deref());
if left_agent.is_some() && right_agent.is_some() && left_agent != right_agent {
return false;
return None;
}
if left.agent_session_id.is_some()
&& right.agent_session_id.is_some()
&& left.agent_session_id == right.agent_session_id
{
return true;
return Some(IdentityMatchStrength::ConversationId);
}
let shared_store = identity_uses_shared_session_store(left_agent.as_deref())
|| identity_uses_shared_session_store(right_agent.as_deref());
if shared_store {
return None;
}
left.agent_session_path.is_some()
(left.agent_session_path.is_some()
&& right.agent_session_path.is_some()
&& left.agent_session_path == right.agent_session_path
&& left.agent_session_path == right.agent_session_path)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.then_some(IdentityMatchStrength::StorePath)
}

pub(crate) fn normalize_codex_session_id(value: &str) -> Option<String> {
Expand Down Expand Up @@ -1250,7 +1313,10 @@ pub(crate) fn normalize_status_agent_name(value: Option<&str>) -> Option<String>
}

pub(crate) fn infer_agent_id_from_path(path: Option<&str>) -> Option<String> {
let lower = path?.to_ascii_lowercase();
let lower = path?.replace('\\', "/").to_ascii_lowercase();
if lower.ends_with("/.zcode/cli/db/db.sqlite") {
return Some("zcode".to_string());
}
if lower.contains("/.cursor/") && (lower.ends_with(".json") || lower.ends_with(".jsonl")) {
return Some("cursor".to_string());
}
Expand Down
9 changes: 5 additions & 4 deletions server/src/domain/repository/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,10 +468,11 @@ impl<'a> DomainRepository<'a> {
.collect()
}

/// The project's rows that can satisfy `identities_match` for an agent
/// identity: same agent session id or same agent session path. A superset
/// of the in-memory match (agent-family checks stay with the caller), read
/// without hydrating the rest of the project.
/// The project's rows that can satisfy `identities_match_strength` for an
/// agent identity: same agent session id or same agent session path. A
/// superset of the in-memory match (agent-family checks and the
/// shared-store carve-out stay with the caller), read without hydrating
/// the rest of the project.
///
/// CDXC:SessionIdentity 2026-09-11 WHY:
/// The live-process identity pass runs on every presentation poll and, while a session's title is still a placeholder, re-hunts a trusted title among the project's other rows each time.
Expand Down
Loading