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
43 changes: 41 additions & 2 deletions atomic-agent/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,11 @@ fn extract_toml_string_value(line: &str) -> Option<String> {
/// * `agent_name` — Agent registry key (e.g., "claude-code")
/// * `agent_display_name` — Human-readable name (e.g., "Claude Code")
/// * `session_id` — Session identifier for the `+tag` suffix
/// * `agent_identity` — Name of a delegated agent identity to sign as,
/// resolved by the CLI hook handler (env var > global setting > active
/// server profile). `None` falls back to `ATOMIC_AGENT_IDENTITY` and then
/// to the plus-tag path, so an environment with nothing configured
/// behaves exactly as before agent identities existed.
///
/// # Returns
///
Expand All @@ -502,13 +507,18 @@ fn extract_toml_string_value(line: &str) -> Option<String> {
///
/// - With identity: `claude+60f5 <lee@atomic.dev>` (with public key ref)
/// - Without: `Claude Code` (no email)
pub fn build_agent_author(agent_name: &str, agent_display_name: &str, session_id: &str) -> Author {
pub fn build_agent_author(
agent_name: &str,
agent_display_name: &str,
session_id: &str,
agent_identity: Option<&str>,
) -> Author {
let options = AgentAuthorOptions {
agent_name,
agent_display_name,
session_id,
identity_dir: None,
agent_identity: None,
agent_identity: agent_identity.map(str::to_string),
};
resolve_agent_author(&options)
}
Expand Down Expand Up @@ -943,13 +953,42 @@ identity_type = "user"
"claude-code",
"Claude Code",
"60f5cbd2-aa23-40ee-9085-4375dd186ce7",
None,
);

// Can't guarantee identity store exists in test env, so just verify
// the author is valid (either tagged or fallback)
assert!(!author.name.is_empty());
}

/// A name that does not resolve must degrade to a usable author, not an
/// error — recording a turn must never fail over identity selection.
#[test]
fn build_agent_author_never_fails_on_an_unresolvable_name() {
let author = build_agent_author("open-code", "OpenCode", "sess1234", Some("no-such-agent"));
assert!(!author.name.is_empty());
}

/// The delegated name must reach the resolver: `build_agent_author` is a
/// thin wrapper over `resolve_agent_author`, so the same inputs — with
/// and without a delegated name — must produce identical authors.
#[test]
fn build_agent_author_threads_the_delegated_name() {
for name in [None, Some("fred+opencode")] {
let via_wrapper = build_agent_author("open-code", "OpenCode", "sess1234", name);
let via_resolver = resolve_agent_author(&AgentAuthorOptions {
agent_name: "open-code",
agent_display_name: "OpenCode",
session_id: "sess1234",
identity_dir: None,
agent_identity: name.map(str::to_string),
});
assert_eq!(via_wrapper.name, via_resolver.name);
assert_eq!(via_wrapper.email, via_resolver.email);
assert_eq!(via_wrapper.identity, via_resolver.identity);
}
}

// extract_toml_string_value

#[test]
Expand Down
4 changes: 3 additions & 1 deletion atomic-agent/src/record/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ use provenance::{
/// - Good prompt: `"Fix the authentication bug in login.rs"`
/// - Slash command or no prompt: `"Add src/main.rs, Cargo.toml"`
///
/// The author is the agent identity.
/// The author is the agent identity — the delegated identity selected for
/// this hook invocation when one is in force, else the plus-tag fallback.
fn build_turn_header(
options: &TurnRecordOptions<'_>,
status: &RepositoryStatus,
Expand All @@ -102,6 +103,7 @@ fn build_turn_header(
&options.session.agent_name,
&options.session.agent_display_name,
&options.session.session_id,
options.agent_identity.as_deref(),
);

ChangeHeader::builder()
Expand Down
11 changes: 11 additions & 0 deletions atomic-agent/src/record/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ pub struct TurnRecordOptions<'a> {
///
/// Used for the change message and the SessionEnvelope's prompt_summary.
pub prompt: Option<String>,

/// Name of the delegated agent identity to sign this turn as, if one
/// is in force.
///
/// Resolved once by the CLI hook handler (`ATOMIC_AGENT_IDENTITY` env var,
/// else the global `agent_identity` setting, else the active server
/// profile's binding) and passed as plain data — this crate never reads
/// config. `None` means no delegated identity was selected, and the
/// author falls back to the plus-tag of the default identity, exactly
/// as before agent identities existed.
pub agent_identity: Option<String>,
}

/// The result of recording a turn as an Atomic change.
Expand Down
4 changes: 3 additions & 1 deletion atomic-agent/src/record/provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ pub(crate) fn build_turn_envelope(
// has no delegated identity — the plus-tag path signs with the human's key
// and there is no certificate to point at, which is precisely the
// difference the field is there to record.
if let Some(urn) = crate::identity::active_delegation_urn(None, None) {
if let Some(urn) =
crate::identity::active_delegation_urn(options.agent_identity.as_deref(), None)
{
builder = builder.delegation_id(urn);
}

Expand Down
4 changes: 4 additions & 0 deletions atomic-agent/src/record/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ fn make_options<'a>(session: &'a AgentSession, event: &'a TurnEvent) -> TurnReco
turn_number: 3,
turn_duration_ms: 12400,
prompt: Some("Fix the authentication bug in login.rs".to_string()),
agent_identity: None,
}
}

Expand Down Expand Up @@ -869,6 +870,7 @@ fn test_record_turn_nonexistent_repo_fails() {
turn_number: 3,
turn_duration_ms: 5000,
prompt: Some("Fix the bug".to_string()),
agent_identity: None,
};

let result = record_turn(Path::new("/nonexistent/repo/path"), &options);
Expand Down Expand Up @@ -1109,6 +1111,7 @@ fn test_orphaned_session_view_duplicates_content_on_merge() {
turn_number: 1,
turn_duration_ms: 1000,
prompt: Some("Bump step10".to_string()),
agent_identity: None,
};
record_turn(repo_root, &options_a).unwrap();

Expand All @@ -1134,6 +1137,7 @@ fn test_orphaned_session_view_duplicates_content_on_merge() {
turn_number: 1,
turn_duration_ms: 1000,
prompt: Some("Bump step70".to_string()),
agent_identity: None,
};
record_turn(repo_root, &options_b)
.expect("record_turn should self-heal an orphaned session view rather than fail");
Expand Down
23 changes: 23 additions & 0 deletions atomic-agent/src/turn/orchestrator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,15 @@ pub struct TurnOrchestrator {
/// Human-readable agent display name (e.g., "Claude Code").
pub(crate) agent_display_name: String,

/// Delegated agent identity to sign recorded turns as, if one is in
/// force for this hook invocation.
///
/// Resolved by the CLI hook handler from the env var / global setting /
/// active server profile chain, and passed as plain data — the
/// orchestrator never reads config itself. `None` keeps the plus-tag
/// fallback, which is the behavior from before agent identities existed.
pub(crate) agent_identity: Option<String>,

/// Managed-run context when a governing lifecycle covers this hook;
/// `None` for direct agent usage (behavior unchanged).
pub(crate) managed_run: Option<ManagedRunContext>,
Expand Down Expand Up @@ -437,6 +446,7 @@ impl TurnOrchestrator {
watcher,
agent_name: "unknown".to_string(),
agent_display_name: "Unknown Agent".to_string(),
agent_identity: None,
managed_run: None,
journal_sink: None,
})
Expand All @@ -457,6 +467,7 @@ impl TurnOrchestrator {
watcher,
agent_name: "unknown".to_string(),
agent_display_name: "Unknown Agent".to_string(),
agent_identity: None,
managed_run: None,
journal_sink: None,
}
Expand All @@ -472,6 +483,18 @@ impl TurnOrchestrator {
self.agent_display_name = display_name.into();
}

/// Set the delegated agent identity that recorded turns sign as.
///
/// Called by the CLI hook handler after construction with the name
/// resolved from the selection chain (`ATOMIC_AGENT_IDENTITY` env var,
/// else the global `agent_identity` setting, else the active server
/// profile's binding). `None` — the default — keeps the plus-tag
/// fallback. The name crosses as plain data; resolution errors were
/// already handled upstream and degrade to `None`.
pub fn set_agent_identity(&mut self, identity: Option<String>) {
self.agent_identity = identity;
}

/// Attach the managed-run context for this hook invocation
/// (see [`ManagedRunContext`]).
pub fn set_managed_run(&mut self, context: ManagedRunContext) {
Expand Down
1 change: 1 addition & 0 deletions atomic-agent/src/turn/orchestrator/session_end.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ impl TurnOrchestrator {
turn_number,
turn_duration_ms,
prompt,
agent_identity: self.agent_identity.clone(),
};
record_turn(&self.repo_root, &record_options)
};
Expand Down
2 changes: 2 additions & 0 deletions atomic-agent/src/turn/orchestrator/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ impl TurnOrchestrator {
turn_number: session.turn_count.saturating_add(1),
turn_duration_ms: 0,
prompt: None,
agent_identity: self.agent_identity.clone(),
};
if crate::record::scope::has_pending_changes(&self.repo_root, &options)? {
return Err(AgentError::RecordFailed {
Expand Down Expand Up @@ -328,6 +329,7 @@ impl TurnOrchestrator {
turn_number,
turn_duration_ms,
prompt,
agent_identity: self.agent_identity.clone(),
};

match record_turn(&self.repo_root, &record_options) {
Expand Down
1 change: 1 addition & 0 deletions atomic-agent/tests/record_view_mismatch_regression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn options<'a>(
turn_number,
turn_duration_ms: 1000,
prompt: Some(prompt.to_string()),
agent_identity: None,
}
}

Expand Down
18 changes: 18 additions & 0 deletions atomic-cli/src/commands/agent/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,24 @@ impl Command for Hooks {
orchestrator
.set_journal_sink(Arc::new(super::owner::OwnerJournalSink::new(&repo_root)));

// The delegated identity to sign recorded turns as, resolved
// through the selection chain (env var > global setting >
// active server profile). `None` keeps the plus-tag fallback,
// so an environment with nothing configured behaves exactly as
// before agent identities existed.
let agent_identity = super::identity::resolve_effective_agent_identity();
match &agent_identity {
Some((name, source)) => {
log::debug!("hooks: recording as agent identity '{name}' ({source})");
}
None => {
log::debug!(
"hooks: no agent identity selected; recording as plus-tag of default"
);
}
}
orchestrator.set_agent_identity(agent_identity.map(|(name, _)| name));

// Under a managed lifecycle, sessions adopt the declared view
// and carry the run stamp (see lifecycle module docs).
if let Some(lc) = &managed {
Expand Down
Loading
Loading