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
96 changes: 76 additions & 20 deletions src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ pub async fn execute_hook(
// Auto-sync: install applicable skills into agent dirs (non-fatal).
// SessionStart refreshes source caches and syncs unconditionally.
let session_start = event == HookEvent::SessionStart;
let notify_about_possible_new_plugins = run_auto_sync(sym, &deps, session_start).await;
let (notify_about_possible_new_plugins, session_start_synced) =
run_auto_sync(sym, &deps, session_start).await;
let reload_skills_after_sync = session_start && session_start_synced;

// SessionStart (once per session) also refreshes every hook's already-
// cached source, so later events dispatch fresh binaries without per-
Expand All @@ -278,8 +280,14 @@ pub async fn execute_hook(
}

// Builtin dispatch → symposium output → host agent output as Value
let builtin_sym_output =
dispatch_builtin(sym, &sym_input, &deps, notify_about_possible_new_plugins).await;
let builtin_sym_output = dispatch_builtin(
sym,
&sym_input,
&deps,
notify_about_possible_new_plugins,
reload_skills_after_sync,
)
.await;
let builtin_agent_output = handler.translate_output(&builtin_sym_output);
let prior_output = builtin_agent_output.to_hook_output();

Expand All @@ -292,6 +300,7 @@ pub async fn execute_hook(
payload.as_ref(),
prior_output,
builtin_sym_output.additional_context().map(String::from),
reload_skills_after_sync,
&deps,
)
.await
Expand Down Expand Up @@ -413,11 +422,18 @@ fn write_hook_trace(agent: HookAgent, event: HookEvent, input: &str, response: &
/// the `Cargo.lock` freshness gate — upstream skill changes land even when the
/// workspace's dependencies are unchanged.
///
/// Returns whether it re-synced because `Cargo.lock` changed mid-session.
async fn run_auto_sync(sym: &Symposium, deps: &Arc<WorkspaceDeps>, session_start: bool) -> bool {
/// Returns `(notify_about_possible_new_plugins, session_start_synced)`.
///
/// The second flag is true when a `SessionStart` hook completed auto-sync
/// successfully (skills may have been installed or refreshed).
async fn run_auto_sync(
sym: &Symposium,
deps: &Arc<WorkspaceDeps>,
session_start: bool,
) -> (bool, bool) {
if !sym.config.auto_sync {
tracing::debug!("auto-sync disabled, skipping");
return false;
return (false, false);
}

let cwd = deps.cwd().to_path_buf();
Expand All @@ -433,7 +449,7 @@ async fn run_auto_sync(sym: &Symposium, deps: &Arc<WorkspaceDeps>, session_start
let state = crate::workspace_state::WorkspaceState::load(sym, root);
if state.sync_is_fresh(root) {
tracing::debug!("auto-sync skipped: Cargo.lock unchanged since last sync");
return false;
return (false, false);
}
}
let dependencies_changed = !session_start && workspace_root.is_some();
Expand All @@ -447,7 +463,7 @@ async fn run_auto_sync(sym: &Symposium, deps: &Arc<WorkspaceDeps>, session_start
tracing::debug!("auto-sync running");
if let Err(e) = crate::sync::sync(sym, deps, update).await {
tracing::warn!(error = %e, "auto-sync during hook failed (continuing)");
return false;
return (false, false);
}

// Record successful sync. If we didn't find the root earlier,
Expand All @@ -459,7 +475,11 @@ async fn run_auto_sync(sym: &Symposium, deps: &Arc<WorkspaceDeps>, session_start
state.workspace_root = Some(root.clone());
state.save(sym, root);
}
dependencies_changed
if session_start {
(dependencies_changed, true)
} else {
(dependencies_changed, false)
}
}

/// Whether the hook pipeline must resolve the workspace crate graph before
Expand Down Expand Up @@ -552,6 +572,7 @@ pub async fn dispatch_builtin(
input: &symposium::InputEvent,
deps: &Arc<WorkspaceDeps>,
notify_about_possible_new_plugins: bool,
reload_skills_after_sync: bool,
) -> symposium::OutputEvent {
match input {
symposium::InputEvent::PreToolUse(_) => {
Expand All @@ -564,7 +585,7 @@ pub async fn dispatch_builtin(
handle_user_prompt_submit(sym, prompt, notify_about_possible_new_plugins).await
}
symposium::InputEvent::SessionStart(session) => {
handle_session_start(sym, session, deps).await
handle_session_start(sym, session, deps, reload_skills_after_sync).await
}
_ => symposium::OutputEvent::empty_for(HookEvent::PreToolUse),
}
Expand All @@ -577,6 +598,7 @@ async fn handle_session_start(
sym: &Symposium,
_payload: &SessionStartInput,
deps: &Arc<WorkspaceDeps>,
reload_skills_after_sync: bool,
) -> OutputEvent {
let fragments = [
discovery_hint(sym, deps).await,
Expand All @@ -587,11 +609,15 @@ async fn handle_session_start(
.flatten()
.collect::<Vec<String>>();

if fragments.is_empty() {
OutputEvent::empty_for(HookEvent::SessionStart)
let mut output = if fragments.is_empty() {
symposium::SessionStartOutput::default()
} else {
OutputEvent::with_context(HookEvent::SessionStart, fragments.join("\n\n"))
symposium::SessionStartOutput::context(fragments.join("\n\n"))
};
if reload_skills_after_sync {
output = output.with_reload_skills();
}
OutputEvent::SessionStart(output)
}

/// Suggest `cargo agents --help` when the active workspace exposes crate-aware plugin subcommands.
Expand Down Expand Up @@ -723,6 +749,7 @@ pub async fn dispatch_plugin_hooks(
original_input: &dyn AgentHookInput,
prior_output: serde_json::Value,
prior_context: Option<String>,
reload_skills_after_sync: bool,
deps: &Arc<WorkspaceDeps>,
) -> Result<serde_json::Value, Vec<u8>> {
let workspace = deps.load().cloned();
Expand Down Expand Up @@ -757,7 +784,7 @@ pub async fn dispatch_plugin_hooks(
let hooks = dispatched_hooks_for_payload(&plugins, sym_input, host_agent, &mut ctx);

let mut output = prior_output;
let mut context = JoinedContext::new(event, prior_context);
let mut context = JoinedContext::new(event, prior_context, reload_skills_after_sync);

for hook in hooks {
tracing::info!(
Expand Down Expand Up @@ -903,11 +930,16 @@ pub async fn dispatch_plugin_hooks(
struct JoinedContext {
event: HookEvent,
text: Option<String>,
reload_skills_after_sync: bool,
}

impl JoinedContext {
fn new(event: HookEvent, prior: Option<String>) -> Self {
Self { event, text: prior }
fn new(event: HookEvent, prior: Option<String>, reload_skills_after_sync: bool) -> Self {
Self {
event,
text: prior,
reload_skills_after_sync,
}
}

/// Add `hook_output`'s context and write the joined text back into
Expand All @@ -932,7 +964,15 @@ impl JoinedContext {
});
}
if let Some(text) = &self.text {
let joined = OutputEvent::with_context(self.event, text.clone());
let joined = if self.event == HookEvent::SessionStart {
let mut session = symposium::SessionStartOutput::context(text.clone());
if self.reload_skills_after_sync {
session = session.with_reload_skills();
}
OutputEvent::SessionStart(session)
} else {
OutputEvent::with_context(self.event, text.clone())
};
merge(output, handler.translate_output(&joined).to_hook_output());
}
}
Expand Down Expand Up @@ -1201,7 +1241,7 @@ mod tests {
None,
None,
));
let output = dispatch_builtin(&sym, &input, &deps, false).await;
let output = dispatch_builtin(&sym, &input, &deps, false, false).await;
assert!(output.additional_context().is_none());
}

Expand All @@ -1217,7 +1257,7 @@ mod tests {
Some("test-session".to_string()),
Some("/tmp".to_string()),
));
let output = dispatch_builtin(&sym, &input, &deps, false).await;
let output = dispatch_builtin(&sym, &input, &deps, false, false).await;
assert!(output.additional_context().is_none());
}

Expand All @@ -1231,10 +1271,26 @@ mod tests {
Some("test-session".to_string()),
Some("/tmp".to_string()),
));
let output = dispatch_builtin(&sym, &input, &deps, false).await;
let output = dispatch_builtin(&sym, &input, &deps, false, false).await;
assert!(output.additional_context().is_none());
}

#[tokio::test]
async fn session_start_requests_skill_reload_after_auto_sync() {
let tmp = tempfile::tempdir().unwrap();
let sym = Symposium::from_dir(tmp.path());
let deps = sym.workspace_deps(tmp.path());
let input = symposium::InputEvent::SessionStart(symposium::SessionStartInput::new(
Some("sess".to_string()),
Some(tmp.path().display().to_string()),
));
let output = dispatch_builtin(&sym, &input, &deps, false, true).await;
let symposium::OutputEvent::SessionStart(session) = output else {
panic!("expected SessionStart output");
};
assert_eq!(session.reload_skills, Some(true));
}

#[test]
fn symposium_output_serializes_with_additional_context() {
let output = symposium::OutputEvent::with_context(
Expand Down
34 changes: 26 additions & 8 deletions src/hook_schema/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,8 @@ pub struct ClaudeSessionStartHookOutput {
pub hook_event_name: String,
#[serde(rename = "additionalContext", skip_serializing_if = "Option::is_none")]
pub additional_context: Option<String>,
#[serde(rename = "reloadSkills", skip_serializing_if = "Option::is_none")]
pub reload_skills: Option<bool>,
#[serde(flatten)]
pub rest: serde_json::Map<String, serde_json::Value>,
}
Expand Down Expand Up @@ -557,17 +559,33 @@ impl AgentHookOutput for ClaudeSessionStartOutput {
Ok(serde_json::from_slice(output)?)
}
fn from_symposium(event: &symposium::OutputEvent) -> Self {
match claude_hook_output_from_symposium("SessionStart", event) {
Some(v) => serde_json::from_value(v).unwrap_or_default(),
None => Self::default(),
let symposium::OutputEvent::SessionStart(output) = event else {
panic!("wrong event type")
};
let needs_hook_output = output.additional_context.is_some()
|| output.reload_skills == Some(true);
if !needs_hook_output {
return Self::default();
}
Self {
hook_specific_output: Some(ClaudeSessionStartHookOutput {
hook_event_name: "SessionStart".into(),
additional_context: output.additional_context.clone(),
reload_skills: output.reload_skills,
rest: serde_json::Map::new(),
}),
..Self::default()
}
}
fn to_symposium(&self) -> symposium::OutputEvent {
symposium::OutputEvent::SessionStart(symposium::SessionStartOutput::new(
self.hook_specific_output
.as_ref()
.and_then(|h| h.additional_context.clone()),
))
let hook = self.hook_specific_output.as_ref();
let mut output = symposium::SessionStartOutput::new(
hook.and_then(|h| h.additional_context.clone()),
);
if hook.and_then(|h| h.reload_skills) == Some(true) {
output = output.with_reload_skills();
}
symposium::OutputEvent::SessionStart(output)
}
fn to_hook_output(&self) -> serde_json::Value {
serde_json::to_value(self).unwrap()
Expand Down
17 changes: 16 additions & 1 deletion symposium-sdk/src/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,18 +478,33 @@ pub struct SessionStartOutput {
skip_serializing_if = "Option::is_none"
)]
pub additional_context: Option<String>,
#[serde(
rename = "reloadSkills",
default,
skip_serializing_if = "Option::is_none"
)]
pub reload_skills: Option<bool>,
}

impl SessionStartOutput {
/// Create an output with all fields specified.
pub fn new(additional_context: Option<String>) -> Self {
Self { additional_context }
Self {
additional_context,
reload_skills: None,
}
}

/// Create an output that injects additional context.
pub fn context(text: impl Into<String>) -> Self {
Self::new(Some(text.into()))
}

/// Ask Claude Code to rescan skills after this hook installs or updates them.
pub fn with_reload_skills(mut self) -> Self {
self.reload_skills = Some(true);
self
}
}

/// Output for a `Stop` event.
Expand Down
22 changes: 22 additions & 0 deletions tests/hook_stdout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,28 @@ async fn sync_progress_stays_off_hook_stdout() {
.unwrap();
}

/// After a successful SessionStart auto-sync, Claude Code receives
/// `reloadSkills: true` so skills installed during the hook are visible
/// in the same session (see #344).
#[tokio::test(flavor = "multi_thread")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I think about it, this is not ideal as for what I was going for, not that something is wrong with this code.

These test checks the hook output for that reloadSkills flag, but doesn't demonstrate that a newly installed skill becomes available in that first session.

The ideal way that I had envisioned is We'd need a real Claude session where the skill doesn't exist before session start, then show that Claude can discover and use it immediately after Symposium installs it.

@gmemuriuki gmemuriuki Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm working to get the right infra for this kind of tests, so, as for the current testing infra I don't think we can test this, because it sync first, and such we can't prove our hypothesis in question here.

I'm going to get that infra ready first, then we can fix this using it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proof that we want is: Symposium installs a previously absent skill during SessionStart, and Claude invokes it on the very first prompt.

The way to achieve this is start with a controlled before-and-after with real Claude, which needs the support of that infra. So, I'll start by implementing that, then come back to this after that has shipped chat

async fn session_start_requests_skill_reload_on_claude() {
with_fixture(TestMode::SimulationOnly, &["plugins0", "workspace0"], async |ctx| {
let workspace = ctx.workspace_root.clone().unwrap();
let output = run_session_start(&ctx, HookAgent::Claude, &workspace);
assert!(output.status.success());
let stdout: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
stdout["hookSpecificOutput"]["reloadSkills"],
true,
"stdout: {}",
stdout
);
Ok(())
})
.await
.unwrap();
}

/// A plugin's install commands run on every dispatch of the hook that needs
/// them; what they print must not reach the agent's stdout.
#[tokio::test(flavor = "multi_thread")]
Expand Down