Skip to content

Commit 4f23cbc

Browse files
gimeneteCopilot
andcommitted
Notify hooks after response delivery
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 5b3a03e commit 4f23cbc

3 files changed

Lines changed: 69 additions & 7 deletions

File tree

rust/src/hooks.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ use crate::types::SessionId;
1919
pub struct HookContext {
2020
/// The session this hook was triggered in.
2121
pub session_id: SessionId,
22+
/// JSON-RPC request ID for this hook invocation.
23+
pub request_id: u64,
24+
}
25+
26+
/// Hook response that was successfully written back to the CLI.
27+
#[derive(Debug, Clone)]
28+
pub struct HookResponseSent {
29+
/// The session this hook was triggered in.
30+
pub session_id: SessionId,
31+
/// JSON-RPC request ID for this hook invocation.
32+
pub request_id: u64,
33+
/// Runtime hook type, such as `userPromptSubmitted` or `preToolUse`.
34+
pub hook_type: String,
2235
}
2336

2437
/// Input for the `preToolUse` hook — received before a tool executes.
@@ -570,6 +583,9 @@ pub trait SessionHooks: Send + Sync + 'static {
570583
}
571584
}
572585

586+
/// Called after a hook response is successfully written back to the CLI.
587+
async fn on_hook_response_sent(&self, _response: HookResponseSent) {}
588+
573589
/// Called before a tool executes. Return `Some(output)` to approve/deny
574590
/// or modify the call, or `None` (default) to pass through unchanged.
575591
async fn on_pre_tool_use(
@@ -680,14 +696,26 @@ pub trait SessionHooks: Send + Sync + 'static {
680696
/// Returns `Ok(Value)` shaped like `{ "output": ... }` on success.
681697
/// If no hook is registered ([`HookOutput::None`]), the output is an empty
682698
/// object: `{ "output": {} }`.
683-
pub(crate) async fn dispatch_hook(
699+
#[cfg(test)]
700+
async fn dispatch_hook(
701+
hooks: &dyn SessionHooks,
702+
session_id: &SessionId,
703+
hook_type: &str,
704+
raw_input: Value,
705+
) -> Result<Value, crate::Error> {
706+
dispatch_hook_for_request(hooks, session_id, 0, hook_type, raw_input).await
707+
}
708+
709+
pub(crate) async fn dispatch_hook_for_request(
684710
hooks: &dyn SessionHooks,
685711
session_id: &SessionId,
712+
request_id: u64,
686713
hook_type: &str,
687714
raw_input: Value,
688715
) -> Result<Value, crate::Error> {
689716
let ctx = HookContext {
690717
session_id: session_id.clone(),
718+
request_id,
691719
};
692720

693721
let event = match hook_type {

rust/src/session.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2385,7 +2385,11 @@ async fn handle_request(
23852385
.unwrap_or(Value::Object(Default::default()));
23862386

23872387
let rpc_result = if let Some(hooks) = hooks {
2388-
match crate::hooks::dispatch_hook(hooks, &sid, hook_type, input).await {
2388+
match crate::hooks::dispatch_hook_for_request(
2389+
hooks, &sid, request.id, hook_type, input,
2390+
)
2391+
.await
2392+
{
23892393
Ok(output) => output,
23902394
Err(e) => {
23912395
warn!(error = %e, hook_type = hook_type, "hook dispatch failed");
@@ -2402,7 +2406,17 @@ async fn handle_request(
24022406
result: Some(rpc_result),
24032407
error: None,
24042408
};
2405-
let _ = client.send_response(&rpc_response).await;
2409+
if client.send_response(&rpc_response).await.is_ok()
2410+
&& let Some(hooks) = hooks
2411+
{
2412+
hooks
2413+
.on_hook_response_sent(crate::hooks::HookResponseSent {
2414+
session_id: sid,
2415+
request_id: request.id,
2416+
hook_type: hook_type.to_string(),
2417+
})
2418+
.await;
2419+
}
24062420
}
24072421

24082422
"userInput.request" => {

rust/tests/session_test.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4144,14 +4144,19 @@ async fn create_session_pair_with_hooks(
41444144

41454145
#[tokio::test]
41464146
async fn hooks_invoke_dispatches_to_session_hooks() {
4147-
use github_copilot_sdk::hooks::{HookEvent, HookOutput, PreToolUseOutput, SessionHooks};
4147+
use github_copilot_sdk::hooks::{
4148+
HookEvent, HookOutput, HookResponseSent, PreToolUseOutput, SessionHooks,
4149+
};
41484150

4149-
struct PolicyHooks;
4151+
struct PolicyHooks {
4152+
response_sent: tokio::sync::mpsc::UnboundedSender<HookResponseSent>,
4153+
}
41504154
#[async_trait]
41514155
impl SessionHooks for PolicyHooks {
41524156
async fn on_hook(&self, event: HookEvent) -> HookOutput {
41534157
match event {
4154-
HookEvent::PreToolUse { input, .. } => {
4158+
HookEvent::PreToolUse { input, ctx } => {
4159+
assert_eq!(ctx.request_id, 300);
41554160
if input.tool_name == "rm" {
41564161
HookOutput::PreToolUse(PreToolUseOutput {
41574162
permission_decision: Some("deny".to_string()),
@@ -4165,9 +4170,18 @@ async fn hooks_invoke_dispatches_to_session_hooks() {
41654170
_ => HookOutput::None,
41664171
}
41674172
}
4173+
4174+
async fn on_hook_response_sent(&self, response: HookResponseSent) {
4175+
self.response_sent.send(response).unwrap();
4176+
}
41684177
}
41694178

4170-
let (_session, mut server) = create_session_pair_with_hooks(Arc::new(PolicyHooks)).await;
4179+
let (response_sent_tx, mut response_sent_rx) =
4180+
tokio::sync::mpsc::unbounded_channel::<HookResponseSent>();
4181+
let (_session, mut server) = create_session_pair_with_hooks(Arc::new(PolicyHooks {
4182+
response_sent: response_sent_tx,
4183+
}))
4184+
.await;
41714185

41724186
// Send a hooks.invoke request for a denied tool
41734187
server
@@ -4195,6 +4209,12 @@ async fn hooks_invoke_dispatches_to_session_hooks() {
41954209
response["result"]["output"]["permissionDecisionReason"],
41964210
"destructive"
41974211
);
4212+
let response_sent = timeout(TIMEOUT, response_sent_rx.recv())
4213+
.await
4214+
.unwrap()
4215+
.unwrap();
4216+
assert_eq!(response_sent.request_id, 300);
4217+
assert_eq!(response_sent.hook_type, "preToolUse");
41984218
}
41994219

42004220
#[tokio::test]

0 commit comments

Comments
 (0)